diff --git a/.github/scripts/update-marketplace.sh b/.github/scripts/update-marketplace.sh new file mode 100755 index 0000000..9ae2374 --- /dev/null +++ b/.github/scripts/update-marketplace.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf 'usage: %s \n' "${0##*/}" >&2 + exit 2 +} + +[[ $# -eq 2 ]] || usage +index=$1 +version=$2 + +if [[ ! -f $index ]]; then + printf 'marketplace index not found: %s\n' "$index" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null; then + printf 'required command not found: jq\n' >&2 + exit 1 +fi + +if ! [[ $version =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + printf 'release version must use X.Y.Z: %s\n' "$version" >&2 + exit 1 +fi + +count=$(jq '[.plugins[] | select(.name == "behavior-diff")] | length' "$index") +if [[ $count -ne 1 ]]; then + printf 'expected one behavior-diff marketplace entry, found %s\n' "$count" >&2 + exit 1 +fi + +tmp=$(mktemp "${index}.tmp.XXXXXX") +trap 'rm -f "$tmp"' EXIT +jq --arg version "$version" ' + (.plugins[] | select(.name == "behavior-diff") | .version) = $version | + (.plugins[] | select(.name == "behavior-diff") | .source.ref) = ("v" + $version) +' "$index" >"$tmp" + +jq -e --arg version "$version" ' + [.plugins[] | + select(.name == "behavior-diff" and + .version == $version and + .source.ref == ("v" + $version))] | + length == 1 +' "$tmp" >/dev/null + +mv "$tmp" "$index" +trap - EXIT diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc0c11e..b4a0375 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_call: pull_request: push: branches: @@ -67,6 +68,7 @@ jobs: - name: Check shell syntax run: | bash -n \ + .github/scripts/*.sh \ bin/behavior-diff \ plugin/scripts/*.sh \ plugin/skills/behavior-diff/scripts/*.sh \ @@ -86,3 +88,6 @@ jobs: - name: Run report contract checks run: bash tests/live-report-contract.sh + + - name: Run release workflow checks + run: bash tests/release-workflow-test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ae5cb07 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,87 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + ci: + if: ${{ !github.event.release.prerelease }} + uses: ./.github/workflows/ci.yml + + marketplace: + if: ${{ !github.event.release.prerelease }} + needs: ci + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out released tag + uses: actions/checkout@v5 + with: + ref: refs/tags/${{ github.event.release.tag_name }} + fetch-depth: 0 + + - name: Validate release + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + if ! [[ $RELEASE_TAG =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + printf 'stable release tag must use vX.Y.Z: %s\n' "$RELEASE_TAG" >&2 + exit 1 + fi + + VERSION=${RELEASE_TAG#v} + CLAUDE_VERSION=$(jq -r '.version' plugin/.claude-plugin/plugin.json) + CODEX_VERSION=$(jq -r '.version' plugin/.codex-plugin/plugin.json) + if [[ $CLAUDE_VERSION != "$VERSION" || $CODEX_VERSION != "$VERSION" ]]; then + printf 'release %s does not match plugin manifests (%s, %s)\n' \ + "$VERSION" "$CLAUDE_VERSION" "$CODEX_VERSION" >&2 + exit 1 + fi + + git fetch origin \ + '+refs/heads/main:refs/remotes/origin/main' + if ! git merge-base --is-ancestor HEAD origin/main; then + printf 'released commit is not on main: %s\n' "$RELEASE_TAG" >&2 + exit 1 + fi + + printf 'VERSION=%s\n' "$VERSION" >>"$GITHUB_ENV" + + - name: Publish marketplace entry + env: + MARKETPLACE_DEPLOY_KEY: ${{ secrets.MARKETPLACE_DEPLOY_KEY }} + run: | + set -euo pipefail + test -n "$MARKETPLACE_DEPLOY_KEY" + umask 077 + + MARKETPLACE=$RUNNER_TEMP/marketplace + SSH_DIR=$RUNNER_TEMP/marketplace-ssh + install -d -m 700 "$SSH_DIR" + trap 'rm -rf "$SSH_DIR"' EXIT + printf '%s\n' "$MARKETPLACE_DEPLOY_KEY" >"$SSH_DIR/key" + curl --fail --silent --show-error https://api.github.com/meta | + jq -r '.ssh_keys[] | "github.com " + .' >"$SSH_DIR/known_hosts" + test -s "$SSH_DIR/known_hosts" + export GIT_SSH_COMMAND="ssh -i $SSH_DIR/key -o IdentitiesOnly=yes -o IdentityAgent=none -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$SSH_DIR/known_hosts" + + git clone --depth 1 git@github.com:spacedock-dev/marketplace.git "$MARKETPLACE" + INDEX=$MARKETPLACE/.claude-plugin/marketplace.json + "$GITHUB_WORKSPACE/.github/scripts/update-marketplace.sh" "$INDEX" "$VERSION" + git -C "$MARKETPLACE" diff --check + if git -C "$MARKETPLACE" diff --quiet -- .claude-plugin/marketplace.json; then + printf 'marketplace already publishes Behavior Diff %s\n' "$VERSION" + exit 0 + fi + + git -C "$MARKETPLACE" add -- .claude-plugin/marketplace.json + git -C "$MARKETPLACE" \ + -c user.name=github-actions \ + -c user.email=actions@github.com \ + commit --signoff -m "behavior-diff $VERSION" + git -C "$MARKETPLACE" push origin HEAD:main diff --git a/AGENTS.md b/AGENTS.md index edcbc65..6ac5f85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ For the full deterministic suite, run: bash tests/hooks-test.sh python3 plugin/skills/behavior-diff/scripts/decisions.py --check bash tests/live-report-contract.sh +bash tests/release-workflow-test.sh ``` For Markdown-only changes, also run `git diff --check`. Do not replace these diff --git a/CODING_GUIDELINES.md b/CODING_GUIDELINES.md index 24e24c0..87f4605 100644 --- a/CODING_GUIDELINES.md +++ b/CODING_GUIDELINES.md @@ -141,11 +141,13 @@ docker run --rm -v "$PWD:/mnt" -w /mnt \ mvdan/shfmt:v3.14.0 -d -i 2 -ci . uvx ruff@0.16.5 format --check --diff . bash -n \ + .github/scripts/*.sh \ bin/behavior-diff \ plugin/scripts/*.sh \ plugin/skills/behavior-diff/scripts/*.sh \ tests/*.sh shellcheck \ + .github/scripts/*.sh \ bin/behavior-diff \ plugin/scripts/*.sh \ plugin/skills/behavior-diff/scripts/*.sh \ @@ -156,6 +158,7 @@ python3 -m py_compile \ bash tests/hooks-test.sh python3 plugin/skills/behavior-diff/scripts/decisions.py --check bash tests/live-report-contract.sh +bash tests/release-workflow-test.sh git diff --check ``` diff --git a/README.md b/README.md index ede22c0..ce02425 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ Use Behavior Diff when you: ## Install -Behavior Diff supports Claude Code and Codex. The commands below are for the -public marketplace release. The marketplace entry is not live yet. +Behavior Diff supports Claude Code and Codex. The commands below install the +public marketplace release. ### Claude Code @@ -97,12 +97,9 @@ In Claude Code, you can also run: /behavior-diff ``` -Behavior Diff finds the changed instruction file and prepares a neutral task. -It shows you the task and the model cost before it starts. The run starts only -after you approve it. - -The standard run starts six fresh agent trials: three without the change and -three with it. Fast mode starts one trial for each side. +Behavior Diff finds the changed instruction file and uses your request as the +comparison task. Once the task is known, it runs the comparison and opens the +report. ## Read the report @@ -143,17 +140,27 @@ Behavior Diff creates two copies of the same project state: 1. The **before** copy uses the committed instruction file. 2. The **after** copy adds only your uncommitted instruction change. -It gives both copies the same task and starts fresh agent sessions. Standard -mode repeats each side three times. This makes one unusual model response less -likely to control the result. +It gives both copies the same task and starts fresh agent sessions. The runner records tool calls, commands, evidence, decisions, and final answers. It converts those traces into a common flow format, compares the two sides, and builds the HTML report. It does not use a model to declare a winner. -The plugin also watches edits to `CLAUDE.md`, `AGENTS.md`, and `SKILL.md`. After -you finish the current task, it can ask whether you want to run Behavior Diff. -It never starts a model run without your approval. +The plugin also watches edits to `CLAUDE.md`, `AGENTS.md`, and `SKILL.md`. +After you finish the current task, the agent can use that task to run Behavior +Diff on the instruction change. + +## Release + +1. Update both plugin manifests to the same `X.Y.Z` version. +2. Merge the version change to `main` and wait for CI. +3. Create a GitHub Release with tag `vX.Y.Z`, targeting `main`. +4. Publish it as a stable release, not a prerelease. +5. Confirm the Release workflow pins the marketplace entry to `vX.Y.Z`. + +The release workflow rejects tags that do not match both plugin manifests or +do not point to a commit on `main`. Drafts and prereleases do not update the +stable marketplace. ### Repository layout diff --git a/plans/2026-09-02-plugin-release-design.md b/plans/2026-09-02-plugin-release-design.md new file mode 100644 index 0000000..f1374aa --- /dev/null +++ b/plans/2026-09-02-plugin-release-design.md @@ -0,0 +1,176 @@ +# Behavior Diff Plugin Release Design + +**Goal:** Publish stable Behavior Diff versions through GitHub Releases and update the Spacedock marketplace automatically to install the released tag. + +## Current state + +- Behavior Diff has deterministic GitHub Actions CI for pull requests and pushes to `main`. +- The Claude Code and Codex plugin manifests both have version `0.3.2`. +- The repository has no Git tags or GitHub Releases yet. +- The marketplace entry has version `0.3.2`, but its source still follows Behavior Diff `main`. +- Behavior Diff has no Actions secrets. +- Subspace updates the same marketplace with a write-enabled SSH deploy key. + +## Release contract + +A maintainer publishes a GitHub Release in `spacedock-dev/behavior-diff`. + +A stable release must meet every rule below: + +- The Release is not a draft or prerelease. +- The tag uses `vX.Y.Z`, with three numeric parts and no suffix. +- The tagged commit is on Behavior Diff `main`. +- `plugin/.claude-plugin/plugin.json` has version `X.Y.Z`. +- `plugin/.codex-plugin/plugin.json` has the same version. +- The repository's deterministic CI passes for the released tag. + +Drafts do not produce a `published` event. Prereleases trigger the workflow but skip all release jobs. They never update the stable marketplace entry. + +GitHub Releases hosts the tag and its normal source archives. Behavior Diff does not build or upload a separate release asset because the marketplace installs the `plugin/` subdirectory from Git. + +## Data flow + +```text +Publish stable GitHub Release + | + v +Validate tag, main ancestry, manifests, and CI + | + v +Clone spacedock-dev/marketplace with a deploy key + | + v +Update behavior-diff version and source.ref + | + v +Validate JSON, commit with sign-off, push marketplace main +``` + +The marketplace entry changes as one unit: + +```json +{ + "name": "behavior-diff", + "source": { + "ref": "vX.Y.Z" + }, + "version": "X.Y.Z" +} +``` + +The updater must find exactly one `behavior-diff` entry. It must reject a missing or duplicate entry. Running it again with the same version must leave the file unchanged. + +## Repository changes + +### Reusable CI + +Add `workflow_call` to `.github/workflows/ci.yml`. The release workflow calls the same `Format` and `Unit` jobs used by pull requests and `main`. It does not copy those checks into a second workflow. + +### Release workflow + +Add `.github/workflows/release.yml` with a `release.published` trigger. + +The workflow: + +1. Skips prereleases. +2. Runs the reusable CI workflow. +3. Checks out the released tag with full Git history. +4. Validates the stable tag format, `main` ancestry, and both manifest versions. +5. Clones the marketplace through SSH. +6. Runs the marketplace updater. +7. Checks the generated diff and JSON. +8. Creates a signed commit with the `github-actions` identity when a change exists. +9. Pushes to marketplace `main` without force. + +The workflow has read-only `contents` permission in Behavior Diff. + +### Marketplace updater + +Add `.github/scripts/update-marketplace.sh` as a small deterministic command. It accepts the marketplace index path and release version. It validates inputs, updates `version` and `source.ref`, validates the result, and replaces the JSON file atomically. + +Keeping this logic outside workflow YAML makes it executable in local tests and keeps the workflow focused on orchestration. + +### Tests + +Add `tests/release-workflow-test.sh` and run it from the existing Unit job. + +The test covers: + +- Updating version and tag reference together. +- Preserving unrelated marketplace entries. +- A second run producing no change. +- Rejecting an invalid version. +- Rejecting a missing Behavior Diff entry. +- Rejecting duplicate Behavior Diff entries. +- The workflow trigger, prerelease guard, manifest checks, CI dependency, and deploy-key use. + +The existing Bash syntax and formatting checks include the new scripts. +`AGENTS.md` and `CODING_GUIDELINES.md` add the release contract to the full +deterministic suite so local and CI verification stay aligned. + +### Operator documentation + +Add a short release section to the existing README: + +1. Update both plugin manifests to the same version. +2. Merge the version change and wait for `main` CI. +3. In GitHub, create a Release with tag `vX.Y.Z` targeting `main`. +4. Publish it as a stable release. +5. Confirm the release workflow and marketplace commit. + +The same README change removes the outdated claim that the marketplace entry +is not live. It also removes product text that says Behavior Diff shows model +cost, waits for approval, and advertises standard or fast run counts. The +replacement says the comparison starts once its task is known, without +exposing internal run modes or trial counts. + +## Authentication + +Use one SSH deploy key dedicated to this integration. + +- Add its public key to `spacedock-dev/marketplace` with write access. +- Store its private key as the `MARKETPLACE_DEPLOY_KEY` Actions secret in `spacedock-dev/behavior-diff`. +- Do not use a personal access token. +- Do not reuse another repository's deploy key. +- Do not commit or print private key material. + +The workflow writes the key to a temporary file with mode `600`, uses strict GitHub host-key checking, and removes the temporary SSH directory on exit. + +## Failure behavior + +- A tag, ancestry, manifest, CI, JSON, or secret failure stops before any marketplace commit. +- A concurrent marketplace change can make the push fail. The workflow does not force push. Rerunning the failed job retries against current marketplace `main`. +- If marketplace already points to the released version and tag, the workflow exits successfully without a commit. +- Because the trigger is `release.published`, a failed workflow does not hide or delete the GitHub Release. Marketplace remains on its last good release. +- Published tags are immutable. Correct a bad release with a new patch version rather than moving the tag. +- If a marketplace release must be withdrawn, revert the marketplace commit separately and publish a corrected patch release. + +## Rollout + +1. Merge the release-process change to Behavior Diff `main`. +2. Provision the dedicated deploy key in both repositories. +3. Publish the first GitHub Release as `v0.3.2`, targeting the release-process commit on `main`. +4. Confirm the workflow changes marketplace `version` to `0.3.2` and `source.ref` to `v0.3.2`. +5. Confirm a clean rerun creates no marketplace commit. + +## Alternatives considered + +### Tag-push workflow + +This matches Subspace. It validates before creating the GitHub Release, so an invalid release never becomes visible. It was not selected because the preferred operator flow is the GitHub Release UI. + +### Manual workflow dispatch + +A workflow form could accept a version, validate `main`, and create the tag and Release. It gives strong guardrails but duplicates the GitHub Release UI and adds more workflow logic. + +### Personal access token + +A fine-grained token could update marketplace. It was not selected because it is tied to a person and normally grants a wider permission surface than one repository deploy key. + +## Non-goals + +- Building binaries or custom release archives. +- Updating a prerelease or edge marketplace channel. +- Publishing from branches other than `main`. +- Moving or overwriting published tags. +- Creating a general release framework for other plugins. diff --git a/plans/2026-09-02-plugin-release-process.md b/plans/2026-09-02-plugin-release-process.md new file mode 100644 index 0000000..03c5016 --- /dev/null +++ b/plans/2026-09-02-plugin-release-process.md @@ -0,0 +1,738 @@ +# Behavior Diff Plugin Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish stable Behavior Diff versions through GitHub Releases and automatically pin the Spacedock marketplace entry to each released tag. + +**Architecture:** A `release.published` workflow reuses the existing deterministic CI, validates the stable release tag and both plugin manifests, then updates marketplace through a dedicated SSH deploy key. A small shell command owns the JSON update so deterministic tests can exercise the release behavior outside GitHub Actions. + +**Tech Stack:** GitHub Actions, Bash 3.2, `jq`, Git, GitHub Releases, SSH deploy keys, existing `shfmt` 3.14.0 and Ruff 0.16.5 checks. + +--- + +## Scope and constraints + +- Stable GitHub Releases only. Drafts and prereleases never update marketplace. +- Release tags use strict `vX.Y.Z` form and point to a commit on `main`. +- Both plugin manifests must equal `X.Y.Z`. +- Marketplace updates both `version` and `source.ref` in one change. +- Use a dedicated write-enabled deploy key, not a personal access token. +- Never force push, move a published tag, print a private key, or invoke a model in CI. +- Keep Claude Code and Codex plugin versions equal. +- Correct the two approved stale README sections in the same branch. +- Use signed commits for every repository commit. + +## Files + +- Create: `.github/scripts/update-marketplace.sh` +- Create: `.github/workflows/release.yml` +- Create: `tests/release-workflow-test.sh` +- Modify: `.github/workflows/ci.yml` +- Modify: `AGENTS.md` +- Modify: `CODING_GUIDELINES.md` +- Modify: `README.md` +- Existing design: `plans/2026-09-02-plugin-release-design.md` + +--- + +### Task 1: Add and implement the marketplace updater + +**Files:** + +- Create: `tests/release-workflow-test.sh` +- Create: `.github/scripts/update-marketplace.sh` + +- [ ] **Step 1: Create the failing updater contract** + +Create `tests/release-workflow-test.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +updater=$here/../.github/scripts/update-marketplace.sh +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +make_index() { + cat >"$1" <<'JSON' +{ + "name": "spacedock", + "plugins": [ + { + "name": "other-plugin", + "source": {"ref": "stable"}, + "version": "1.0.0" + }, + { + "name": "behavior-diff", + "source": { + "source": "git-subdir", + "url": "https://github.com/spacedock-dev/behavior-diff.git", + "path": "plugin", + "ref": "main" + }, + "version": "0.3.1" + } + ] +} +JSON +} + +printf '[release] Update one marketplace entry\n' +index=$tmp/marketplace.json +make_index "$index" +"$updater" "$index" 0.3.2 +jq -e ' + [.plugins[] | + select(.name == "behavior-diff" and + .version == "0.3.2" and + .source.ref == "v0.3.2")] | + length == 1 +' "$index" >/dev/null || fail 'Behavior Diff entry was not updated' +jq -e ' + [.plugins[] | + select(.name == "other-plugin" and + .version == "1.0.0" and + .source.ref == "stable")] | + length == 1 +' "$index" >/dev/null || fail 'unrelated entry changed' + +printf '[release] Keep repeated updates unchanged\n' +cp "$index" "$tmp/expected.json" +"$updater" "$index" 0.3.2 +cmp -s "$tmp/expected.json" "$index" || fail 'second update changed the index' + +printf '[release] Ignore predictable temp symlinks\n' +symlink_index=$tmp/symlink-marketplace.json +victim=$tmp/victim +make_index "$symlink_index" +printf 'sentinel\n' >"$victim" +ln -s "$victim" "${symlink_index}.tmp" +"$updater" "$symlink_index" 0.3.2 +[[ $(cat "$victim") == sentinel ]] || + fail 'updater followed a predictable temp symlink' +[[ ! -L $symlink_index ]] || + fail 'updater replaced the index with a symlink' + +printf '[release] Reject invalid versions and entry counts\n' +make_index "$tmp/invalid-version.json" +if "$updater" "$tmp/invalid-version.json" v0.3.2 >/dev/null 2>&1; then + fail 'updater accepted a version with v prefix' +fi + +make_index "$tmp/missing.json" +jq '.plugins |= map(select(.name != "behavior-diff"))' \ + "$tmp/missing.json" >"$tmp/missing.tmp" +mv "$tmp/missing.tmp" "$tmp/missing.json" +if "$updater" "$tmp/missing.json" 0.3.2 >/dev/null 2>&1; then + fail 'updater accepted a missing Behavior Diff entry' +fi + +make_index "$tmp/duplicate.json" +jq '.plugins += [.plugins[] | select(.name == "behavior-diff")]' \ + "$tmp/duplicate.json" >"$tmp/duplicate.tmp" +mv "$tmp/duplicate.tmp" "$tmp/duplicate.json" +if "$updater" "$tmp/duplicate.json" 0.3.2 >/dev/null 2>&1; then + fail 'updater accepted duplicate Behavior Diff entries' +fi + +printf 'ok — release workflow contract passed\n' +``` + +- [ ] **Step 2: Run the test and verify the red state** + +Run: + +```bash +bash tests/release-workflow-test.sh +``` + +Expected: failure because `.github/scripts/update-marketplace.sh` does not exist. + +- [ ] **Step 3: Implement the marketplace updater** + +Create `.github/scripts/update-marketplace.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf 'usage: %s \n' "${0##*/}" >&2 + exit 2 +} + +[[ $# -eq 2 ]] || usage +index=$1 +version=$2 + +if [[ ! -f $index ]]; then + printf 'marketplace index not found: %s\n' "$index" >&2 + exit 1 +fi + +if ! [[ $version =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + printf 'release version must use X.Y.Z: %s\n' "$version" >&2 + exit 1 +fi + +count=$(jq '[.plugins[] | select(.name == "behavior-diff")] | length' "$index") +if [[ $count -ne 1 ]]; then + printf 'expected one behavior-diff marketplace entry, found %s\n' "$count" >&2 + exit 1 +fi + +tmp=$(mktemp "${index}.tmp.XXXXXX") +trap 'rm -f "$tmp"' EXIT +jq --arg version "$version" ' + (.plugins[] | select(.name == "behavior-diff") | .version) = $version | + (.plugins[] | select(.name == "behavior-diff") | .source.ref) = ("v" + $version) +' "$index" >"$tmp" + +jq -e --arg version "$version" ' + [.plugins[] | + select(.name == "behavior-diff" and + .version == $version and + .source.ref == ("v" + $version))] | + length == 1 +' "$tmp" >/dev/null + +mv "$tmp" "$index" +trap - EXIT +``` + +- [ ] **Step 4: Format and run the focused contract** + +Run: + +```bash +docker run --rm -u "$(id -u):$(id -g)" \ + -v "$PWD:/mnt" -w /mnt \ + mvdan/shfmt:v3.14.0 -w -i 2 -ci \ + .github/scripts/update-marketplace.sh tests/release-workflow-test.sh +bash tests/release-workflow-test.sh +``` + +Expected: `ok — release workflow contract passed`. + +- [ ] **Step 5: Commit the updater slice** + +Run: + +```bash +git add .github/scripts/update-marketplace.sh tests/release-workflow-test.sh +git commit --signoff -m "feat: add marketplace release updater" +``` + +--- + +### Task 2: Add the stable GitHub Release workflow + +**Files:** + +- Modify: `tests/release-workflow-test.sh` +- Create: `.github/workflows/release.yml` +- Modify: `.github/workflows/ci.yml` +- Modify: `AGENTS.md` +- Modify: `CODING_GUIDELINES.md` + +- [ ] **Step 1: Extend the contract with workflow invariants** + +Insert these helpers after `fail()` in `tests/release-workflow-test.sh`: + +```bash +require_literal() { + local literal=$1 + local file=$2 + local message=$3 + grep -Fq -- "$literal" "$file" || fail "$message" +} + +require_order() { + local first=$1 + local second=$2 + local file=$3 + local message=$4 + local first_match + local second_match + if ! first_match=$(grep -nF -- "$first" "$file"); then + fail "$message" + fi + if ! second_match=$(grep -nF -- "$second" "$file"); then + fail "$message" + fi + (( ${first_match%%:*} < ${second_match%%:*} )) || fail "$message" +} + +has_only_safe_marketplace_push() { + local file=$1 + local push_lines + if ! push_lines=$(grep -F "git -C \"\$MARKETPLACE\" push" "$file"); then + return 1 + fi + [[ $push_lines == " git -C \"\$MARKETPLACE\" push origin HEAD:main" ]] +} +``` + +Insert these paths after `updater=...`: + +```bash +release_workflow=$here/../.github/workflows/release.yml +ci_workflow=$here/../.github/workflows/ci.yml +``` + +Insert this block before the final success line: + +```bash +printf '[release] Keep GitHub Release and security invariants\n' +require_literal 'workflow_call:' "$ci_workflow" \ + 'CI is not reusable from the release workflow' +require_literal 'types: [published]' "$release_workflow" \ + 'release workflow does not use the published event' +require_literal "ref: refs/tags/\${{ github.event.release.tag_name }}" \ + "$release_workflow" 'release workflow does not check out the tag namespace' +require_literal "if: \${{ !github.event.release.prerelease }}" \ + "$release_workflow" 'release workflow does not skip prereleases' +require_literal 'uses: ./.github/workflows/ci.yml' "$release_workflow" \ + 'release workflow does not reuse deterministic CI' +require_literal 'needs: ci' "$release_workflow" \ + 'marketplace update is not gated by CI' +require_literal 'plugin/.claude-plugin/plugin.json' "$release_workflow" \ + 'release workflow does not validate the Claude manifest' +require_literal 'plugin/.codex-plugin/plugin.json' "$release_workflow" \ + 'release workflow does not validate the Codex manifest' +require_literal 'git merge-base --is-ancestor HEAD origin/main' \ + "$release_workflow" 'release workflow does not require a main commit' +require_literal "MARKETPLACE_DEPLOY_KEY: \${{ secrets.MARKETPLACE_DEPLOY_KEY }}" \ + "$release_workflow" 'release workflow does not use the deploy-key secret' +require_order 'umask 077' ">\"\$SSH_DIR/key\"" "$release_workflow" \ + 'release workflow does not restrict permissions before writing the key' +require_literal "update-marketplace.sh\" \"\$INDEX\" \"\$VERSION\"" \ + "$release_workflow" 'release workflow does not call the tested updater' +has_only_safe_marketplace_push "$release_workflow" || + fail 'release workflow must contain only the exact non-force marketplace push' +forced_workflow=$tmp/forced-release.yml +sed 's/push origin HEAD:main/push origin HEAD:main --force/' \ + "$release_workflow" >"$forced_workflow" +if has_only_safe_marketplace_push "$forced_workflow"; then + fail 'marketplace push check accepted a trailing force flag' +fi +``` + +- [ ] **Step 2: Run the contract and verify the red state** + +Run: + +```bash +bash tests/release-workflow-test.sh +``` + +Expected: failure because `.github/workflows/release.yml` does not exist and CI has no `workflow_call` trigger. + +- [ ] **Step 3: Make the existing CI reusable and include the new test** + +Update `.github/workflows/ci.yml`: + +```yaml +on: + workflow_call: + pull_request: + push: + branches: + - main +``` + +Add `.github/scripts/*.sh` to the existing `bash -n` command: + +```yaml + - name: Check shell syntax + run: | + bash -n \ + .github/scripts/*.sh \ + bin/behavior-diff \ + plugin/scripts/*.sh \ + plugin/skills/behavior-diff/scripts/*.sh \ + tests/*.sh +``` + +Add this Unit step after the live-report contract: + +```yaml + - name: Run release workflow checks + run: bash tests/release-workflow-test.sh +``` + +Add the new command to the full deterministic suite in both `AGENTS.md` and +`CODING_GUIDELINES.md`: + +```bash +bash tests/release-workflow-test.sh +``` + +- [ ] **Step 4: Create the release workflow** + +Create `.github/workflows/release.yml`: + +```yaml +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + ci: + if: ${{ !github.event.release.prerelease }} + uses: ./.github/workflows/ci.yml + + marketplace: + if: ${{ !github.event.release.prerelease }} + needs: ci + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out released tag + uses: actions/checkout@v5 + with: + ref: refs/tags/${{ github.event.release.tag_name }} + fetch-depth: 0 + + - name: Validate release + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + if ! [[ $RELEASE_TAG =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + printf 'stable release tag must use vX.Y.Z: %s\n' "$RELEASE_TAG" >&2 + exit 1 + fi + + VERSION=${RELEASE_TAG#v} + CLAUDE_VERSION=$(jq -r '.version' plugin/.claude-plugin/plugin.json) + CODEX_VERSION=$(jq -r '.version' plugin/.codex-plugin/plugin.json) + if [[ $CLAUDE_VERSION != "$VERSION" || $CODEX_VERSION != "$VERSION" ]]; then + printf 'release %s does not match plugin manifests (%s, %s)\n' \ + "$VERSION" "$CLAUDE_VERSION" "$CODEX_VERSION" >&2 + exit 1 + fi + + git fetch origin \ + '+refs/heads/main:refs/remotes/origin/main' + if ! git merge-base --is-ancestor HEAD origin/main; then + printf 'released commit is not on main: %s\n' "$RELEASE_TAG" >&2 + exit 1 + fi + + printf 'VERSION=%s\n' "$VERSION" >>"$GITHUB_ENV" + + - name: Publish marketplace entry + env: + MARKETPLACE_DEPLOY_KEY: ${{ secrets.MARKETPLACE_DEPLOY_KEY }} + run: | + set -euo pipefail + test -n "$MARKETPLACE_DEPLOY_KEY" + umask 077 + + MARKETPLACE=$RUNNER_TEMP/marketplace + SSH_DIR=$RUNNER_TEMP/marketplace-ssh + install -d -m 700 "$SSH_DIR" + trap 'rm -rf "$SSH_DIR"' EXIT + printf '%s\n' "$MARKETPLACE_DEPLOY_KEY" >"$SSH_DIR/key" + curl --fail --silent --show-error https://api.github.com/meta | + jq -r '.ssh_keys[] | "github.com " + .' >"$SSH_DIR/known_hosts" + test -s "$SSH_DIR/known_hosts" + export GIT_SSH_COMMAND="ssh -i $SSH_DIR/key -o IdentitiesOnly=yes -o IdentityAgent=none -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$SSH_DIR/known_hosts" + + git clone --depth 1 git@github.com:spacedock-dev/marketplace.git "$MARKETPLACE" + INDEX=$MARKETPLACE/.claude-plugin/marketplace.json + "$GITHUB_WORKSPACE/.github/scripts/update-marketplace.sh" "$INDEX" "$VERSION" + git -C "$MARKETPLACE" diff --check + if git -C "$MARKETPLACE" diff --quiet -- .claude-plugin/marketplace.json; then + printf 'marketplace already publishes Behavior Diff %s\n' "$VERSION" + exit 0 + fi + + git -C "$MARKETPLACE" add -- .claude-plugin/marketplace.json + git -C "$MARKETPLACE" \ + -c user.name=github-actions \ + -c user.email=actions@github.com \ + commit --signoff -m "behavior-diff $VERSION" + git -C "$MARKETPLACE" push origin HEAD:main +``` + +- [ ] **Step 5: Run the focused workflow contract** + +Run: + +```bash +bash tests/release-workflow-test.sh +``` + +Expected: `ok — release workflow contract passed`. + +- [ ] **Step 6: Validate workflow syntax** + +Run: + +```bash +docker run --rm -v "$PWD:/repo" -w /repo \ + rhysd/actionlint:1.7.7 +``` + +Expected: no output and exit status 0. + +- [ ] **Step 7: Commit the workflow slice** + +Run: + +```bash +git add .github/workflows/ci.yml .github/workflows/release.yml \ + AGENTS.md CODING_GUIDELINES.md tests/release-workflow-test.sh +git commit --signoff -m "ci: publish releases to marketplace" +``` + +--- + +### Task 3: Update operator and product documentation + +**Files:** + +- Modify: `README.md:43-105` + +- [ ] **Step 1: Correct the public usage text** + +Remove this sentence from the Install section: + +```text +The marketplace entry is not live yet. +``` + +Replace the outdated paragraph and run-count section at current lines 100-105 with: + +```markdown +Behavior Diff finds the changed instruction file and uses your request as the +comparison task. Once the task is known, it runs the comparison and opens the +report. +``` + +This text must not mention run modes, trial counts, confirmation, or model cost. + +- [ ] **Step 2: Add the release operator process** + +Append this section before the contributor guidance at the end of `README.md`: + +```markdown +## Release + +1. Update both plugin manifests to the same `X.Y.Z` version. +2. Merge the version change to `main` and wait for CI. +3. Create a GitHub Release with tag `vX.Y.Z`, targeting `main`. +4. Publish it as a stable release, not a prerelease. +5. Confirm the Release workflow pins the marketplace entry to `vX.Y.Z`. + +The release workflow rejects tags that do not match both plugin manifests or +do not point to a commit on `main`. Drafts and prereleases do not update the +stable marketplace. +``` + +- [ ] **Step 3: Check Markdown and stale wording** + +Run: + +```bash +git diff --check +grep -nE 'not live yet|model cost|Fast mode|six fresh agent trials|starts only after' README.md +``` + +Expected: `git diff --check` exits 0. `grep` prints no matches and exits 1. + +- [ ] **Step 4: Commit the documentation slice** + +Run: + +```bash +git add README.md +git commit --signoff -m "docs: document plugin releases" +``` + +--- + +### Task 4: Run full deterministic verification and review + +**Files:** + +- Verify all changed files. + +- [ ] **Step 1: Format the new shell files** + +Run: + +```bash +docker run --rm -u "$(id -u):$(id -g)" \ + -v "$PWD:/mnt" -w /mnt \ + mvdan/shfmt:v3.14.0 -w -i 2 -ci \ + .github/scripts/update-marketplace.sh tests/release-workflow-test.sh +``` + +Expected: formatter exits 0. + +- [ ] **Step 2: Run formatting checks** + +Run: + +```bash +docker run --rm -v "$PWD:/mnt" -w /mnt \ + mvdan/shfmt:v3.14.0 -d -i 2 -ci . +uvx ruff@0.16.5 format --check --diff . +``` + +Expected: no shfmt diff and Ruff reports all Python files formatted. + +- [ ] **Step 3: Run syntax and workflow checks** + +Run: + +```bash +bash -n \ + .github/scripts/*.sh \ + bin/behavior-diff \ + plugin/scripts/*.sh \ + plugin/skills/behavior-diff/scripts/*.sh \ + tests/*.sh +python3 -m py_compile \ + plugin/skills/behavior-diff/scripts/decisions.py \ + plugin/skills/behavior-diff/scripts/render.py +docker run --rm -v "$PWD:/repo" -w /repo \ + rhysd/actionlint:1.7.7 +``` + +Expected: all commands exit 0 with no actionlint findings. + +- [ ] **Step 4: Run the full deterministic suite** + +Run: + +```bash +bash tests/hooks-test.sh +python3 plugin/skills/behavior-diff/scripts/decisions.py --check +bash tests/live-report-contract.sh +bash tests/release-workflow-test.sh +claude plugin validate plugin +git diff --check +``` + +Expected: all four deterministic checks and plugin validation pass. + +- [ ] **Step 5: Remove verification bytecode** + +Run: + +```bash +rm -rf plugin/skills/behavior-diff/scripts/__pycache__ +``` + +Expected: no generated Python bytecode remains. + +- [ ] **Step 6: Complete independent read-only review** + +Give the reviewer the approved design, current branch diff, `AGENTS.md`, and `REVIEWER_GUIDELINES.md`. Require a GO or evidence-backed findings. Fix every valid finding and rerun the affected checks before proceeding. + +--- + +### Task 5: Provision the dedicated marketplace deploy key + +**External state:** + +- Add one deploy key to `spacedock-dev/marketplace`. +- Add one Actions secret to `spacedock-dev/behavior-diff`. + +- [ ] **Step 1: Confirm no key with the release title exists** + +Run: + +```bash +gh api repos/spacedock-dev/marketplace/keys \ + --jq '.[] | select(.title == "behavior-diff-release") | .id' +gh secret list --repo spacedock-dev/behavior-diff +``` + +Expected: no `behavior-diff-release` deploy key and no `MARKETPLACE_DEPLOY_KEY` secret. If either exists, stop and inspect it rather than replacing it silently. + +- [ ] **Step 2: Generate, register, and remove the key pair atomically** + +Run the following as one shell command so the trap always removes the local key +material: + +```bash +set -euo pipefail +KEY_DIR=$(mktemp -d) +trap 'rm -rf "$KEY_DIR"' EXIT +chmod 700 "$KEY_DIR" +ssh-keygen -q -t ed25519 -N '' \ + -C 'behavior-diff marketplace release' \ + -f "$KEY_DIR/id_ed25519" +gh api --method POST repos/spacedock-dev/marketplace/keys \ + -f title='behavior-diff-release' \ + -F key=@"$KEY_DIR/id_ed25519.pub" \ + -F read_only=false +gh secret set MARKETPLACE_DEPLOY_KEY \ + --repo spacedock-dev/behavior-diff \ + <"$KEY_DIR/id_ed25519" +``` + +Expected: GitHub creates a write-enabled marketplace deploy key and updates the +Behavior Diff Actions secret. The exit trap removes both local key files +whether the command succeeds or fails. Do not print either file. + +- [ ] **Step 3: Verify names and permissions only** + +Run: + +```bash +gh api repos/spacedock-dev/marketplace/keys \ + --jq '.[] | select(.title == "behavior-diff-release") | {title,read_only}' +gh secret list --repo spacedock-dev/behavior-diff +``` + +Expected: the deploy key is listed with `read_only: false` and the secret name +is listed. No local key material remains. + +--- + +### Task 6: Prepare branch delivery + +**Files:** + +- Review all branch commits and changed files. + +- [ ] **Step 1: Confirm branch and commit state** + +Run: + +```bash +git status --short --branch +git log --format='%h %s%n%b' origin/main..HEAD +``` + +Expected: the worktree is clean and every commit contains a `Signed-off-by` line. + +- [ ] **Step 2: Present integration options** + +Present the normal choices: merge locally, push and create a pull request, keep the branch, or discard it. Do not push or merge without the user's choice. + +- [ ] **Step 3: After merge, publish the first release only on request** + +The first release should be `v0.3.2` and target the release-process commit on `main`. Publishing it is a separate external action. Do not create the GitHub Release unless the user explicitly asks. diff --git a/tests/release-workflow-test.sh b/tests/release-workflow-test.sh new file mode 100755 index 0000000..1ad23f6 --- /dev/null +++ b/tests/release-workflow-test.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +updater=$here/../.github/scripts/update-marketplace.sh +release_workflow=$here/../.github/workflows/release.yml +ci_workflow=$here/../.github/workflows/ci.yml +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +require_literal() { + local literal=$1 + local file=$2 + local message=$3 + grep -Fq -- "$literal" "$file" || fail "$message" +} + +require_order() { + local first=$1 + local second=$2 + local file=$3 + local message=$4 + local first_match + local second_match + if ! first_match=$(grep -nF -- "$first" "$file"); then + fail "$message" + fi + if ! second_match=$(grep -nF -- "$second" "$file"); then + fail "$message" + fi + ((${first_match%%:*} < ${second_match%%:*})) || fail "$message" +} + +has_only_safe_marketplace_push() { + local file=$1 + local push_lines + if ! push_lines=$(grep -F "git -C \"\$MARKETPLACE\" push" "$file"); then + return 1 + fi + [[ $push_lines == " git -C \"\$MARKETPLACE\" push origin HEAD:main" ]] +} + +make_index() { + cat >"$1" <<'JSON' +{ + "name": "spacedock", + "plugins": [ + { + "name": "other-plugin", + "source": {"ref": "stable"}, + "version": "1.0.0" + }, + { + "name": "behavior-diff", + "source": { + "source": "git-subdir", + "url": "https://github.com/spacedock-dev/behavior-diff.git", + "path": "plugin", + "ref": "main" + }, + "version": "0.3.1" + } + ] +} +JSON +} + +printf '[release] Update one marketplace entry\n' +index=$tmp/marketplace.json +make_index "$index" +"$updater" "$index" 0.3.2 +jq -e ' + [.plugins[] | + select(.name == "behavior-diff" and + .version == "0.3.2" and + .source.ref == "v0.3.2")] | + length == 1 +' "$index" >/dev/null || fail 'Behavior Diff entry was not updated' +jq -e ' + [.plugins[] | + select(.name == "other-plugin" and + .version == "1.0.0" and + .source.ref == "stable")] | + length == 1 +' "$index" >/dev/null || fail 'unrelated entry changed' + +printf '[release] Keep repeated updates unchanged\n' +cp "$index" "$tmp/expected.json" +"$updater" "$index" 0.3.2 +cmp -s "$tmp/expected.json" "$index" || fail 'second update changed the index' + +printf '[release] Ignore predictable temp symlinks\n' +symlink_index=$tmp/symlink-marketplace.json +victim=$tmp/victim +make_index "$symlink_index" +printf 'sentinel\n' >"$victim" +ln -s "$victim" "${symlink_index}.tmp" +"$updater" "$symlink_index" 0.3.2 +[[ $(cat "$victim") == sentinel ]] || + fail 'updater followed a predictable temp symlink' +[[ ! -L $symlink_index ]] || + fail 'updater replaced the index with a symlink' + +printf '[release] Reject invalid versions and entry counts\n' +make_index "$tmp/invalid-version.json" +if "$updater" "$tmp/invalid-version.json" v0.3.2 >/dev/null 2>&1; then + fail 'updater accepted a version with v prefix' +fi + +make_index "$tmp/missing.json" +jq '.plugins |= map(select(.name != "behavior-diff"))' \ + "$tmp/missing.json" >"$tmp/missing.tmp" +mv "$tmp/missing.tmp" "$tmp/missing.json" +if "$updater" "$tmp/missing.json" 0.3.2 >/dev/null 2>&1; then + fail 'updater accepted a missing Behavior Diff entry' +fi + +make_index "$tmp/duplicate.json" +jq '.plugins += [.plugins[] | select(.name == "behavior-diff")]' \ + "$tmp/duplicate.json" >"$tmp/duplicate.tmp" +mv "$tmp/duplicate.tmp" "$tmp/duplicate.json" +if "$updater" "$tmp/duplicate.json" 0.3.2 >/dev/null 2>&1; then + fail 'updater accepted duplicate Behavior Diff entries' +fi + +printf '[release] Keep GitHub Release and security invariants\n' +require_literal 'workflow_call:' "$ci_workflow" \ + 'CI is not reusable from the release workflow' +require_literal 'types: [published]' "$release_workflow" \ + 'release workflow does not use the published event' +require_literal "ref: refs/tags/\${{ github.event.release.tag_name }}" \ + "$release_workflow" 'release workflow does not check out the tag namespace' +require_literal "if: \${{ !github.event.release.prerelease }}" \ + "$release_workflow" 'release workflow does not skip prereleases' +require_literal 'uses: ./.github/workflows/ci.yml' "$release_workflow" \ + 'release workflow does not reuse deterministic CI' +require_literal 'needs: ci' "$release_workflow" \ + 'marketplace update is not gated by CI' +require_literal 'plugin/.claude-plugin/plugin.json' "$release_workflow" \ + 'release workflow does not validate the Claude manifest' +require_literal 'plugin/.codex-plugin/plugin.json' "$release_workflow" \ + 'release workflow does not validate the Codex manifest' +require_literal 'git merge-base --is-ancestor HEAD origin/main' \ + "$release_workflow" 'release workflow does not require a main commit' +require_literal "MARKETPLACE_DEPLOY_KEY: \${{ secrets.MARKETPLACE_DEPLOY_KEY }}" \ + "$release_workflow" 'release workflow does not use the deploy-key secret' +require_order 'umask 077' ">\"\$SSH_DIR/key\"" "$release_workflow" \ + 'release workflow does not restrict permissions before writing the key' +require_literal "update-marketplace.sh\" \"\$INDEX\" \"\$VERSION\"" \ + "$release_workflow" 'release workflow does not call the tested updater' +has_only_safe_marketplace_push "$release_workflow" || + fail 'release workflow must contain only the exact non-force marketplace push' +forced_workflow=$tmp/forced-release.yml +sed 's/push origin HEAD:main/push origin HEAD:main --force/' \ + "$release_workflow" >"$forced_workflow" +if has_only_safe_marketplace_push "$forced_workflow"; then + fail 'marketplace push check accepted a trailing force flag' +fi + +printf 'ok — release workflow contract passed\n'