diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..bef8e8bd7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,205 @@ +name: Release + +on: + workflow_dispatch: + inputs: + release: + description: New annotated release tag (vX.Y.Z) + required: true + type: string + candidate_sha: + description: Full final candidate commit (dispatch from a branch at this exact commit) + required: true + type: string + change_author: + description: GitHub login of the associated merged PR author + required: true + type: string + +permissions: + contents: read + +concurrency: + group: stable-release + cancel-in-progress: false + +jobs: + verify: + name: Full exact release candidate + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout final candidate without write credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ inputs.candidate_sha }} + + - name: Verify final candidate identity and version metadata + env: + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + RELEASE_REF: ${{ inputs.release }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + RUN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + [[ "$CANDIDATE_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$RELEASE_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "$WORKFLOW_SHA" = "$CANDIDATE_SHA" + test "$RUN_SHA" = "$CANDIDATE_SHA" + test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" + test -z "$(git status --porcelain)" + test "v$(cat VERSION)" = "$RELEASE_REF" + bin/agent-workflows-release check-version --root . + if git show-ref --verify --quiet "refs/tags/$RELEASE_REF"; then + echo "Release tag already exists; refusing to reuse it" >&2 + exit 1 + fi + + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + with: + ruby-version: "3.4" + + - name: Install validation gems + run: | + # JSON 3 changes malformed-input diagnostics used by the full suite. + gem install json -v 2.21.2 --no-document + gem install rubocop -v "$(tr -d '[:space:]' < .rubocop-version)" --no-document --conservative + gem install json_schemer -v "$(tr -d '[:space:]' < .json-schemer-version)" --no-document --conservative + ruby -rjson -e 'puts "Validation JSON #{JSON::VERSION}"; abort "unexpected JSON version" unless JSON::VERSION == "2.21.2"' + + - name: Run every validation suite before tagging + env: + CI: "true" + run: bin/validate + + - name: Confirm validation preserved the candidate + env: + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + run: | + test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" + test -z "$(git status --porcelain)" + + release: + needs: verify + permissions: + actions: read + contents: write + pull-requests: read + runs-on: ubuntu-latest + environment: stable-release + steps: + - name: Checkout the exact release ref + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ inputs.candidate_sha }} + + - name: Verify independent human approval before creating a tag + id: approval + env: + APPROVED_COMMIT: ${{ inputs.candidate_sha }} + CHANGE_AUTHOR: ${{ inputs.change_author }} + GH_TOKEN: ${{ github.token }} + RELEASE_ACTOR: ${{ github.actor }} + RELEASE_REF: ${{ inputs.release }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + WORKFLOW_PATH: .github/workflows/release.yml + run: | + set -euo pipefail + associated_authors="$( + gh api "repos/$REPOSITORY/commits/$APPROVED_COMMIT/pulls" \ + --jq '[.[] | select(.merged_at != null) | .user.login] | unique' + )" + test "$(jq 'length' <<<"$associated_authors")" -eq 1 + test "$(jq -r '.[0]' <<<"$associated_authors")" = "$CHANGE_AUTHOR" + + approvals="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID/approvals")" + approval_reviewer="$( + jq -er '[.[] | + select(.state == "approved") | + select(.user.type == "User") | + select(any(.environments[]?; .name == "stable-release")) | + .user.login] | last // empty' <<<"$approvals" + )" + + test "${approval_reviewer,,}" != "${CHANGE_AUTHOR,,}" + test "${approval_reviewer,,}" != "${RELEASE_ACTOR,,}" + echo "reviewer=$approval_reviewer" >> "$GITHUB_OUTPUT" + + - name: Create the immutable annotated tag after full validation and approval + id: tag + env: + GH_TOKEN: ${{ github.token }} + APPROVED_COMMIT: ${{ inputs.candidate_sha }} + RELEASE_REF: ${{ inputs.release }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$APPROVED_COMMIT" + bin/agent-workflows-release check-version --root . + tag_object="$(jq -n --arg tag "$RELEASE_REF" --arg object "$APPROVED_COMMIT" \ + '{tag: $tag, message: ("Agent Workflows " + $tag), object: $object, type: "commit"}' | \ + gh api --method POST "repos/$REPOSITORY/git/tags" --input - --jq .sha)" + jq -n --arg ref "refs/tags/$RELEASE_REF" --arg sha "$tag_object" \ + '{ref: $ref, sha: $sha}' | \ + gh api --method POST "repos/$REPOSITORY/git/refs" --input - + git fetch origin "refs/tags/$RELEASE_REF:refs/tags/$RELEASE_REF" + bin/agent-workflows-release verify-tag --root . --release "$RELEASE_REF" \ + --approved-commit "$APPROVED_COMMIT" --expected-tag-object "$tag_object" + echo "object=$tag_object" >> "$GITHUB_OUTPUT" + + - name: Record protected-environment approval evidence + env: + APPROVAL_REVIEWER: ${{ steps.approval.outputs.reviewer }} + APPROVED_COMMIT: ${{ inputs.candidate_sha }} + CHANGE_AUTHOR: ${{ inputs.change_author }} + EXPECTED_TAG_OBJECT: ${{ steps.tag.outputs.object }} + RELEASE_ACTOR: ${{ github.actor }} + RELEASE_REF: ${{ inputs.release }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + WORKFLOW_PATH: .github/workflows/release.yml + run: | + set -euo pipefail + bin/agent-workflows-release record-receipt \ + --root . \ + --release "$RELEASE_REF" \ + --approved-commit "$APPROVED_COMMIT" \ + --expected-tag-object "$EXPECTED_TAG_OBJECT" \ + --environment stable-release \ + --change-author "$CHANGE_AUTHOR" \ + --release-actor "$RELEASE_ACTOR" \ + --approval-reviewer "$APPROVAL_REVIEWER" \ + --repository "$REPOSITORY" \ + --workflow-run-id "$RUN_ID" \ + --workflow-run-attempt "$RUN_ATTEMPT" \ + --workflow-path "$WORKFLOW_PATH" \ + --workflow-run-url "$RUN_URL" \ + --recorded-at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --receipt agent-workflows-release-receipt.json + + - name: Publish release and durable receipt + env: + GH_TOKEN: ${{ github.token }} + RELEASE_REF: ${{ inputs.release }} + run: | + set -euo pipefail + if gh release view "$RELEASE_REF" >/dev/null 2>&1; then + echo "Release already exists; refusing to replace durable release evidence: $RELEASE_REF" >&2 + exit 1 + fi + gh release create "$RELEASE_REF" \ + agent-workflows-release-receipt.json \ + --verify-tag \ + --title "Agent Workflows $RELEASE_REF" \ + --generate-notes diff --git a/CONTEXT.md b/CONTEXT.md index 2d3fa987f..fa61caa3f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,6 +2,13 @@ Portable language for multi-agent PR-batch workflows: how coordinators, workers, and a coordination backend talk about ownership, liveness, and batch lifecycle. Keep repo-specific policy, backend names, dashboards, and domain vocabulary in each consumer repo's `AGENTS.md` seam or local docs. The companion glossary for source-pack distribution, install-path, seam, readiness, and review terms is [docs/source-pack-glossary.md](docs/source-pack-glossary.md). +For the source pack itself, **stable channel** means one protected immutable +annotated `vX.Y.Z` tag plus protected-environment approval and a durable receipt +bound to its peeled exact commit. **Development channel** means an explicitly +selected mutable branch-following install. These delivery channels are distinct +from a consumer repository's beta/RC/final release phase. Agent Workflows does +not require or verify cryptographic tag signatures. + ## Language ### Ownership diff --git a/README.md b/README.md index b5e8c3284..673698682 100644 --- a/README.md +++ b/README.md @@ -86,16 +86,11 @@ default. ## Quick Start -Clone the workflow pack and install it into the agent host you use: - -```bash -git clone https://github.com/shakacode/agent-workflows "$HOME/src/agent-workflows" -cd "$HOME/src/agent-workflows" -bin/install-agent-workflows --host codex -``` - +For a stable first install, follow the [authenticated release bootstrap](docs/release-channel.md#install-update-and-roll-back). +It checks a verifier against an independently trusted SHA-256 pin, verifies the +exact tag and protected release evidence, and only then executes the installer. Use `--host claude` for Claude Code, or `--target "$HOME/.agents"` for an -explicit shared agent home. +explicit shared agent home. Do not execute an installer from an unverified tag. New to the pack? Follow [Getting Started](docs/getting-started.md) for prerequisites with versions, one host install, one repo adoption, and a first @@ -127,19 +122,19 @@ delivery route: ordinary flat skills or the native `scw` plugin. Install into the default Codex home: ```bash -bin/install-agent-workflows --host codex +bin/install-agent-workflows --host codex --release vX.Y.Z ``` Install into the default Claude Code home: ```bash -bin/install-agent-workflows --host claude +bin/install-agent-workflows --host claude --release vX.Y.Z ``` Install into a different agent home, such as `~/.agents`: ```bash -bin/install-agent-workflows --host codex --target "$HOME/.agents" +bin/install-agent-workflows --host codex --target "$HOME/.agents" --release vX.Y.Z ``` The installer copies: @@ -149,19 +144,22 @@ The installer copies: - selected `bin/*` helpers to `/bin/`; - install metadata to `/.agent-workflows-install.json`. -The default `--delivery-mode flat` installs those skills directly. When the -native `scw` plugin is enabled, retain the installer-managed companion assets -without a second flat skill tree: +The default `--delivery-mode flat` installs those skills directly. With the +release-pinned Claude native `scw` plugin enabled, complete the +[exact-release bootstrap](docs/release-channel.md#install-update-and-roll-back) +before retaining the companion assets without a second flat skill tree: ```bash -bin/install-agent-workflows \ - --host codex \ +"$source/bin/install-agent-workflows" \ + --host claude --source "$source" \ + --release "$release" \ --delivery-mode plugin-companion ``` -The selected delivery mode is durable install state. Repeated installs, -`upgrade-agent-workflows`, rollback, and `agent-stack sync` replay it unless an -explicit `--delivery-mode` changes it. +The selected delivery mode is durable install state. Stable install, update, +and rollback always name an exact annotated `vX.Y.Z` tag; development branch +following requires `--channel development`. Neither path silently crosses +channels. See [Stable Release Channel](docs/release-channel.md). Add `/bin` to `PATH` if you want `agent-workflow-seam-doctor`, `agent-workflows-doctor`, `agent-workflows-refresh`, `agent-workflows-status`, @@ -182,16 +180,18 @@ Code exposes `skills/verify/SKILL.md` as `/scw:verify`. Add and install the Claude Code marketplace plugin with: ```text -/plugin marketplace add shakacode/agent-workflows +/plugin marketplace add shakacode/agent-workflows@vX.Y.Z /plugin install scw@agent-workflows ``` -Add and install the Codex marketplace plugin with: - -```bash -codex plugin marketplace add shakacode/agent-workflows -codex plugin add scw@agent-workflows -``` +For stable Codex skills, use the verified +[copy bootstrap](docs/release-channel.md#install-update-and-roll-back). +The current native Codex URL route is **development/unverified**: marketplace +`--ref` pins only the catalog, while the `scw` entry separately fetches the +default branch without a plugin ref or SHA. Stable companion assets do not +make that native plugin stable. See +[Native Plugin Paths](docs/installation-and-upgrades.md#native-plugin-paths) +for the explicitly development-only route. The manifests point at the existing `./skills/` tree; native installation does not copy helper binaries, write @@ -237,11 +237,11 @@ Codex when an existing session must rediscover changed skills or instructions. ## Consumer Repo Adoption -From each repository that should use these workflows, initialize and validate a -starter seam in one command: +For a stable install, initialize and validate each consumer repository against +the installed exact-release target (`$HOME/.claude` for a Claude install): ```bash -agent-workflow-seam-doctor --init --shared "$HOME/src/agent-workflows" +agent-workflow-seam-doctor --init --shared "$HOME/.codex" ``` The initializer preserves existing repo-owned seam content. It detects executable @@ -254,9 +254,12 @@ configuration. Supply both commands explicitly to complete the seam in one run: agent-workflow-seam-doctor --init \ --validate-command 'bin/validate' \ --test-command 'bin/test' \ - --shared "$HOME/src/agent-workflows" + --shared "$HOME/.codex" ``` +Only development-channel installs should instead use a mutable source checkout +such as `--shared "$HOME/src/agent-workflows"`. + Simple explicit commands forward wrapper arguments automatically. Explicit `npm run` commands add npm's `--` separator; `pnpm run` and `yarn run` pass the arguments directly. Compound shell expressions are preserved verbatim; include @@ -414,13 +417,14 @@ ruby bin/codex-plugin-manifest-check Check the installed pack: ```bash -agent-workflows-status --host codex +agent-workflows-status --host codex --release vX.Y.Z ``` Upgrade and validate a consumer repo seam: ```bash -upgrade-agent-workflows --host codex --consumer-root /path/to/consumer/repo +upgrade-agent-workflows --host codex --release vX.Y.Z \ + --consumer-root /path/to/consumer/repo ``` Long-running agents keep whatever skill text they already loaded. Let active diff --git a/bin/agent-stack-test.bash b/bin/agent-stack-test.bash index 2fb93bc4d..7c1bd7227 100755 --- a/bin/agent-stack-test.bash +++ b/bin/agent-stack-test.bash @@ -2,6 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export AGENT_WORKFLOWS_CHANNEL=development source "$ROOT/test/agent_stack/support.bash" source "$ROOT/test/agent_stack/install_test.bash" source "$ROOT/test/agent_stack/doctor_install_test.bash" diff --git a/bin/agent-workflows-doctor-test.bash b/bin/agent-workflows-doctor-test.bash index eb43920db..583cef92b 100755 --- a/bin/agent-workflows-doctor-test.bash +++ b/bin/agent-workflows-doctor-test.bash @@ -44,6 +44,10 @@ def status_payload(status, installed_version:, available_version:, guidance: nil "installed_revision" => installed_version && "installed-revision", "available_version" => available_version, "available_revision" => available_version && "available-revision", + "channel" => "stable", + "release_ref" => "v1.0.0", + "available_release_ref" => "v1.0.0", + "exact_commit" => installed_version && "installed-revision", "checked_remote" => false, "reason" => nil, "guidance" => guidance, diff --git a/bin/agent-workflows-release b/bin/agent-workflows-release new file mode 100755 index 000000000..529e5f957 --- /dev/null +++ b/bin/agent-workflows-release @@ -0,0 +1,441 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "digest" +require "open3" +require "optparse" +require "time" +require "tmpdir" + +class ReleaseError < StandardError; end + +def read_json(path) + value = JSON.parse(File.read(path, encoding: "UTF-8")) + raise ReleaseError, "#{path} must contain a JSON object" unless value.is_a?(Hash) + + value +rescue Errno::ENOENT + raise ReleaseError, "missing release metadata: #{path}" +rescue JSON::ParserError => e + raise ReleaseError, "invalid JSON in #{path}: #{e.message}" +end + +def read_json_array(path) + value = JSON.parse(File.read(path, encoding: "UTF-8")) + raise ReleaseError, "#{path} must contain a JSON array" unless value.is_a?(Array) + + value +rescue Errno::ENOENT + raise ReleaseError, "missing release metadata: #{path}" +rescue JSON::ParserError => e + raise ReleaseError, "invalid JSON in #{path}: #{e.message}" +end + +def checked_version_values(version:, claude:, codex:) + raise ReleaseError, "VERSION must be a stable semantic version" unless version.match?(/\A\d+\.\d+\.\d+\z/) + unless codex == version && (claude.nil? || claude == version) + raise ReleaseError, "release metadata versions do not match VERSION #{version}" + end + + { + "status" => "VERSION_OK", + "version" => version, + "metadata" => { "claude" => claude, "codex" => codex } + } +end + +def checked_versions(root) + version_path = File.join(root, "VERSION") + version = File.read(version_path, encoding: "UTF-8").strip + claude = read_json(File.join(root, ".claude-plugin", "plugin.json"))["version"] + codex = read_json(File.join(root, ".codex-plugin", "plugin.json"))["version"] + checked_version_values(version:, claude:, codex:) +rescue Errno::ENOENT + raise ReleaseError, "missing release metadata: #{version_path}" +end + +def run_git(root, *args) + stdout, stderr, status = Open3.capture3("git", "-C", root, *args) + return stdout.force_encoding("UTF-8").scrub.strip if status.success? + + detail = stderr.force_encoding("UTF-8").scrub.strip + raise ReleaseError, "git #{args.join(' ')} failed#{detail.empty? ? '' : ": #{detail}"}" +rescue Errno::ENOENT + raise ReleaseError, "git is required for release verification" +end + +def json_from_git(root, commit, path) + value = JSON.parse(run_git(root, "show", "#{commit}:#{path}")) + raise ReleaseError, "#{path} at #{commit} must contain a JSON object" unless value.is_a?(Hash) + + value +rescue JSON::ParserError => e + raise ReleaseError, "invalid JSON in #{path} at #{commit}: #{e.message}" +end + +def checked_versions_at_commit(root, commit) + checked_version_values( + version: run_git(root, "show", "#{commit}:VERSION").strip, + claude: json_from_git(root, commit, ".claude-plugin/plugin.json")["version"], + codex: json_from_git(root, commit, ".codex-plugin/plugin.json")["version"] + ) +end + +def verify_tag(root:, release:, approved_commit:, expected_tag_object:) + match = release.match(/\Av(\d+\.\d+\.\d+)\z/) + raise ReleaseError, "release must be an exact vX.Y.Z tag" unless match + unless approved_commit.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/) + raise ReleaseError, "approved commit must be a full object ID" + end + unless expected_tag_object.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/) + raise ReleaseError, "expected tag object must be a full object ID" + end + + tag_ref = "refs/tags/#{release}" + tag_object = run_git(root, "rev-parse", "--verify", tag_ref) + raise ReleaseError, "tag moved: expected #{expected_tag_object}, found #{tag_object}" unless tag_object == expected_tag_object + + object_type = run_git(root, "cat-file", "-t", tag_object) + raise ReleaseError, "#{release} must be an annotated tag, found #{object_type}" unless object_type == "tag" + + peeled_commit = run_git(root, "rev-parse", "--verify", "#{tag_ref}^{commit}") + unless peeled_commit == approved_commit + raise ReleaseError, "#{release} peels to #{peeled_commit}, not approved commit #{approved_commit}" + end + + versions = checked_versions_at_commit(root, peeled_commit) + unless versions.fetch("version") == match[1] + raise ReleaseError, "#{release} does not match VERSION #{versions.fetch('version')} at #{peeled_commit}" + end + + { + "status" => "TAG_VERIFIED", + "release_ref" => release, + "version" => versions.fetch("version"), + "tag_object" => tag_object, + "peeled_commit" => peeled_commit + } +end + +def required_option(options, name) + options.fetch(name) { raise ReleaseError, "--#{name.to_s.tr('_', '-')} is required" } +end + +def github_login!(label, login) + return if login.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\z/) && !login.downcase.end_with?("[bot]") + + raise ReleaseError, "#{label} must identify a human GitHub user" +end + +def record_receipt(options) + environment = required_option(options, :environment) + raise ReleaseError, "stable releases require the stable-release protected environment" unless environment == "stable-release" + + change_author = required_option(options, :change_author) + release_actor = required_option(options, :release_actor) + approval_reviewer = required_option(options, :approval_reviewer) + github_login!("change author", change_author) + github_login!("release actor", release_actor) + github_login!("approval reviewer", approval_reviewer) + if approval_reviewer.casecmp?(change_author) || approval_reviewer.casecmp?(release_actor) + raise ReleaseError, "approval reviewer must be a non-author human who did not initiate the release" + end + + repository = required_option(options, :repository) + unless repository.match?(%r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z}) + raise ReleaseError, "repository must use owner/name form" + end + + workflow_run_id = Integer(required_option(options, :workflow_run_id), 10) + workflow_run_attempt = Integer(required_option(options, :workflow_run_attempt), 10) + raise ReleaseError, "workflow run ID and attempt must be positive integers" unless workflow_run_id.positive? && workflow_run_attempt.positive? + + workflow_path = required_option(options, :workflow_path) + unless workflow_path == ".github/workflows/release.yml" + raise ReleaseError, "workflow path must identify .github/workflows/release.yml" + end + + workflow_run_url = required_option(options, :workflow_run_url) + expected_run_url = "https://github.com/#{repository}/actions/runs/#{workflow_run_id}" + raise ReleaseError, "workflow run URL must identify the bound GitHub Actions run" unless workflow_run_url == expected_run_url + + recorded_at = required_option(options, :recorded_at) + Time.iso8601(recorded_at) + + verified = verify_tag( + root: options.fetch(:root), + release: required_option(options, :release), + approved_commit: required_option(options, :approved_commit), + expected_tag_object: required_option(options, :expected_tag_object) + ) + receipt = { + "schema" => "agent-workflows-stable-release-v1", + "channel" => "stable", + "release_ref" => verified.fetch("release_ref"), + "version" => verified.fetch("version"), + "tag_object" => verified.fetch("tag_object"), + "peeled_commit" => verified.fetch("peeled_commit"), + "recorded_at" => recorded_at, + "approval" => { + "environment" => environment, + "reviewer" => approval_reviewer, + "change_author" => change_author, + "exact_commit" => verified.fetch("peeled_commit"), + "human_non_author" => true + }, + "verification" => { + "suite" => "full", + "exact_commit" => verified.fetch("peeled_commit"), + "before_tag" => true + }, + "workflow" => { + "repository" => repository, + "path" => workflow_path, + "run_id" => workflow_run_id, + "run_attempt" => workflow_run_attempt, + "actor" => release_actor, + "run_url" => workflow_run_url + } + } + receipt_path = File.expand_path(required_option(options, :receipt)) + File.write(receipt_path, "#{JSON.pretty_generate(receipt)}\n", encoding: "UTF-8") + { "status" => "RECEIPT_WRITTEN", "receipt" => receipt_path, "release" => receipt } +rescue ArgumentError + raise ReleaseError, "release run identifiers and --recorded-at must be valid" +end + +def verify_receipt(options) + receipt_path = File.expand_path(required_option(options, :receipt)) + receipt = read_json(receipt_path) + raise ReleaseError, "GitHub release provenance is required" unless options[:github_release] + + github_release = read_json(File.expand_path(options.fetch(:github_release))) + + unless receipt["schema"] == "agent-workflows-stable-release-v1" && receipt["channel"] == "stable" + raise ReleaseError, "receipt must use the stable release receipt schema" + end + + approval = receipt["approval"] + workflow = receipt["workflow"] + unless approval.is_a?(Hash) && workflow.is_a?(Hash) + raise ReleaseError, "receipt approval and workflow evidence must be JSON objects" + end + unless approval["environment"] == "stable-release" && approval["human_non_author"] == true + raise ReleaseError, "receipt lacks qualifying protected-environment approval" + end + + begin + Time.iso8601(receipt.fetch("recorded_at")) + rescue ArgumentError, KeyError + raise ReleaseError, "receipt lacks a valid recording timestamp" + end + github_login!("change author", approval.fetch("change_author", "")) + github_login!("approval reviewer", approval.fetch("reviewer", "")) + github_login!("release actor", workflow.fetch("actor", "")) + if approval.fetch("reviewer").casecmp?(approval.fetch("change_author")) || + approval.fetch("reviewer").casecmp?(workflow.fetch("actor")) + raise ReleaseError, "receipt reviewer is not independent of the author and release actor" + end + + verified = verify_tag( + root: options.fetch(:root), + release: receipt.fetch("release_ref") { raise ReleaseError, "receipt is missing release_ref" }, + approved_commit: receipt.fetch("peeled_commit") { raise ReleaseError, "receipt is missing peeled_commit" }, + expected_tag_object: receipt.fetch("tag_object") { raise ReleaseError, "receipt is missing tag_object" } + ) + unless approval["exact_commit"] == verified.fetch("peeled_commit") && + receipt["version"] == verified.fetch("version") && + workflow["run_url"].to_s.match?(%r{\Ahttps://github\.com/[^/]+/[^/]+/actions/runs/\d+\z}) + raise ReleaseError, "receipt evidence does not bind the verified release" + end + + unless receipt["verification"] == { "suite" => "full", "exact_commit" => verified.fetch("peeled_commit"), "before_tag" => true } + raise ReleaseError, "receipt lacks full exact-candidate verification before tagging" + end + + release_ref = verified.fetch("release_ref") + repository = workflow["repository"].to_s + expected_repository = required_option(options, :repository) + raise ReleaseError, "receipt provenance does not match the expected repository" unless repository == expected_repository + + expected_asset_url = "https://github.com/#{repository}/releases/download/#{release_ref}/agent-workflows-release-receipt.json" + assets = github_release["assets"] + matching_assets = assets.is_a?(Array) ? assets.select { |asset| asset.is_a?(Hash) && asset["name"] == "agent-workflows-release-receipt.json" } : [] + unless github_release["tag_name"] == release_ref && github_release["draft"] == false && + github_release["prerelease"] == false && matching_assets.length == 1 + raise ReleaseError, "GitHub release provenance does not identify one published receipt asset for #{release_ref}" + end + + asset = matching_assets.fetch(0) + expected_digest = "sha256:#{Digest::SHA256.file(receipt_path).hexdigest}" + raise ReleaseError, "GitHub release asset digest does not match the downloaded receipt" unless asset["digest"] == expected_digest + unless asset["state"] == "uploaded" && asset["browser_download_url"] == expected_asset_url + raise ReleaseError, "GitHub release asset metadata does not bind the downloaded receipt" + end + + expected_release_url = "https://github.com/#{repository}/releases/tag/#{release_ref}" + unless github_release["html_url"] == expected_release_url && + github_release.dig("author", "type") == "Bot" && + github_release.dig("author", "login") == "github-actions[bot]" && + asset["size"] == File.size(receipt_path) && + asset.dig("uploader", "type") == "Bot" && + asset.dig("uploader", "login") == "github-actions[bot]" + raise ReleaseError, "GitHub release asset does not bind the GitHub Actions release publisher and exact size" + end + + raise ReleaseError, "GitHub workflow run provenance is required" unless options[:github_workflow_run] + + github_workflow_run = read_json(File.expand_path(options.fetch(:github_workflow_run))) + unless repository.match?(%r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z}) && + github_workflow_run["id"] == workflow["run_id"] && + github_workflow_run["run_attempt"] == workflow["run_attempt"] && + workflow["path"] == ".github/workflows/release.yml" && + github_workflow_run["path"] == workflow.fetch("path") && + github_workflow_run["event"] == "workflow_dispatch" && + github_workflow_run["status"] == "completed" && + github_workflow_run["conclusion"] == "success" && + github_workflow_run["html_url"] == workflow["run_url"] && + github_workflow_run["head_sha"] == verified.fetch("peeled_commit") && + github_workflow_run.dig("actor", "login") == workflow["actor"] && + github_workflow_run.dig("actor", "type") == "User" && + github_workflow_run.dig("repository", "full_name") == repository && + github_workflow_run.dig("repository", "private") == false + raise ReleaseError, "GitHub workflow run metadata does not bind the exact repository, release tag, workflow, and head" + end + + raise ReleaseError, "GitHub approval provenance is required" unless options[:github_workflow_approvals] + + github_workflow_approvals = read_json_array(File.expand_path(options.fetch(:github_workflow_approvals))) + matching_approval = github_workflow_approvals.any? do |review| + review.is_a?(Hash) && + review["state"] == "approved" && + review.dig("user", "type") == "User" && + review.dig("user", "login") == approval["reviewer"] && + review["environments"].is_a?(Array) && + review["environments"].any? { |environment| environment.is_a?(Hash) && environment["name"] == "stable-release" } + end + raise ReleaseError, "GitHub review history lacks the receipt's protected-environment approval" unless matching_approval + + { "status" => "RECEIPT_VERIFIED", "receipt" => receipt_path, "release" => receipt } +rescue KeyError => e + raise ReleaseError, "receipt is incomplete: #{e.message}" +end + +def download_release_evidence(url, path, label = "published release evidence") + output, status = Open3.capture2e( + "curl", "--proto", "=https", "--tlsv1.2", "--fail", "--silent", "--show-error", "--location", + "--header", "Accept: application/vnd.github+json", "--header", "X-GitHub-Api-Version: 2022-11-28", + "--output", path, url + ) + raise ReleaseError, "#{label} is unavailable at #{url}: #{output.strip}" unless status.success? +rescue Errno::ENOENT + raise ReleaseError, "curl is required for published release verification" +end + +def verify_published(options) + repository = required_option(options, :repository) + release = required_option(options, :release) + unless repository.match?(%r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z}) && release.match?(/\Av\d+\.\d+\.\d+\z/) + raise ReleaseError, "published release requires a repository and exact vX.Y.Z tag" + end + + Dir.mktmpdir("agent-workflows-release-evidence") do |temp| + receipt_path = File.join(temp, "receipt.json") + download_release_evidence( + "https://github.com/#{repository}/releases/download/#{release}/agent-workflows-release-receipt.json", + receipt_path, "protected release receipt" + ) + receipt = read_json(receipt_path) + unless receipt["release_ref"] == release && receipt["peeled_commit"] == required_option(options, :approved_commit) && + receipt["tag_object"] == required_option(options, :expected_tag_object) + raise ReleaseError, "published receipt does not bind the requested release identity" + end + + workflow = receipt["workflow"] + unless workflow.is_a?(Hash) && [workflow["run_id"], workflow["run_attempt"]].all? { |value| value.is_a?(Integer) && value.positive? } + raise ReleaseError, "stable release receipt lacks a valid workflow run ID and attempt" + end + + api = "https://api.github.com/repos/#{repository}" + run_url = "#{api}/actions/runs/#{workflow.fetch('run_id')}" + evidence = { + github_release: "#{api}/releases/tags/#{release}", + github_workflow_run: "#{run_url}/attempts/#{workflow.fetch('run_attempt')}", + github_workflow_approvals: "#{run_url}/approvals" + }.to_h do |key, url| + path = File.join(temp, "#{key}.json") + download_release_evidence(url, path) + [key, path] + end + verified = verify_receipt(options.merge(evidence).merge(receipt: receipt_path)) + { "status" => "PUBLISHED_RELEASE_VERIFIED", "release" => verified.fetch("release") } + end +end + +command = ARGV.shift +options = { root: Dir.pwd, json: ARGV.delete("--json") ? true : false } +parser = OptionParser.new do |opts| + opts.banner = "Usage: agent-workflows-release COMMAND [options]" + opts.on("--root DIR", "Source-pack root") { |value| options[:root] = File.expand_path(value) } + opts.on("--json", "Emit JSON") { options[:json] = true } + opts.on("--release TAG", "Exact stable release tag") { |value| options[:release] = value } + opts.on("--approved-commit SHA", "Approved exact commit") { |value| options[:approved_commit] = value } + opts.on("--expected-tag-object SHA", "Expected immutable annotated-tag object") do |value| + options[:expected_tag_object] = value + end + opts.on("--environment NAME", "Protected release environment") { |value| options[:environment] = value } + opts.on("--change-author LOGIN", "Author of the approved change") { |value| options[:change_author] = value } + opts.on("--release-actor LOGIN", "Actor who initiated the release") { |value| options[:release_actor] = value } + opts.on("--approval-reviewer LOGIN", "Protected-environment reviewer") do |value| + options[:approval_reviewer] = value + end + opts.on("--workflow-run-url URL", "Durable GitHub Actions run URL") { |value| options[:workflow_run_url] = value } + opts.on("--repository OWNER/NAME", "Canonical GitHub repository") { |value| options[:repository] = value } + opts.on("--workflow-run-id ID", "GitHub Actions workflow run ID") { |value| options[:workflow_run_id] = value } + opts.on("--workflow-run-attempt N", "GitHub Actions workflow run attempt") do |value| + options[:workflow_run_attempt] = value + end + opts.on("--workflow-path PATH", "Repository-relative workflow path") { |value| options[:workflow_path] = value } + opts.on("--recorded-at TIMESTAMP", "Receipt recording timestamp") { |value| options[:recorded_at] = value } + opts.on("--receipt PATH", "Release receipt path") { |value| options[:receipt] = value } + opts.on("--github-release PATH", "GitHub release API metadata") { |value| options[:github_release] = value } + opts.on("--github-workflow-run PATH", "GitHub workflow run API metadata") do |value| + options[:github_workflow_run] = value + end + opts.on("--github-workflow-approvals PATH", "GitHub workflow approval API metadata") do |value| + options[:github_workflow_approvals] = value + end +end + +begin + parser.parse!(ARGV) + raise ReleaseError, "unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty? + + payload = case command + when "check-version" + checked_versions(options.fetch(:root)) + when "verify-tag" + verify_tag( + root: options.fetch(:root), + release: options.fetch(:release) { raise ReleaseError, "--release is required" }, + approved_commit: options.fetch(:approved_commit) { raise ReleaseError, "--approved-commit is required" }, + expected_tag_object: options.fetch(:expected_tag_object) do + raise ReleaseError, "--expected-tag-object is required" + end + ) + when "record-receipt" + record_receipt(options) + when "verify-receipt" + verify_receipt(options) + when "verify-published" + verify_published(options) + else + raise ReleaseError, "unknown command: #{command.inspect}" + end + puts(options[:json] ? JSON.generate(payload) : payload.values_at("status", "release_ref", "version").compact.join(" ")) +rescue OptionParser::ParseError, ReleaseError => e + payload = { "status" => "RELEASE_CHECK_FAILED", "reason" => e.message } + warn(options[:json] ? JSON.generate(payload) : "#{payload.fetch('status')}: #{e.message}") + exit 2 +end diff --git a/bin/agent-workflows-release-test.rb b/bin/agent-workflows-release-test.rb new file mode 100755 index 000000000..7abe9bc54 --- /dev/null +++ b/bin/agent-workflows-release-test.rb @@ -0,0 +1,658 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "fileutils" +require "digest" +require "json" +require "minitest/autorun" +require "open3" +require "tmpdir" +require "shellwords" + +SCRIPT = File.expand_path("agent-workflows-release", __dir__) + +class AgentWorkflowsReleaseTest < Minitest::Test + def test_check_version_accepts_one_shared_stable_version + with_release_root do |root| + write_versions(root, "1.2.3") + + output, status = Open3.capture2e("ruby", SCRIPT, "check-version", "--root", root, "--json") + payload = JSON.parse(output) + + assert status.success?, output + assert_equal "VERSION_OK", payload.fetch("status") + assert_equal "1.2.3", payload.fetch("version") + assert_equal "1.2.3", payload.dig("metadata", "claude") + assert_equal "1.2.3", payload.dig("metadata", "codex") + end + end + + def test_check_version_accepts_commit_versioned_claude_manifest + with_release_root do |root| + write_versions(root, "1.2.3") + File.write(File.join(root, ".claude-plugin", "plugin.json"), JSON.generate("name" => "scw")) + payload, status = run_json("check-version", "--root", root) + assert status.success?, payload.inspect + assert_nil payload.dig("metadata", "claude") + end + end + + def test_check_version_rejects_mismatch_malformed_and_missing_metadata + with_release_root do |root| + write_versions(root, "1.2.3") + manifest_path = File.join(root, ".codex-plugin", "plugin.json") + File.write(manifest_path, "#{JSON.generate('name' => 'scw', 'version' => '1.2.4')}\n") + + payload, status = run_json("check-version", "--root", root) + + assert_equal 2, status.exitstatus + assert_equal "RELEASE_CHECK_FAILED", payload.fetch("status") + assert_includes payload.fetch("reason"), "do not match" + + write_versions(root, "release-1.2.3") + payload, status = run_json("check-version", "--root", root) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "stable semantic version" + + FileUtils.rm_f(File.join(root, ".claude-plugin", "plugin.json")) + payload, status = run_json("check-version", "--root", root) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "missing release metadata" + end + end + + def test_verify_tag_accepts_an_immutable_annotated_tag_at_the_approved_commit + with_release_repository("1.2.3") do |root, commit| + git(root, "tag", "-a", "v1.2.3", "-m", "Agent Workflows v1.2.3") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + + payload, status = run_json( + "verify-tag", + "--root", root, + "--release", "v1.2.3", + "--approved-commit", commit, + "--expected-tag-object", tag_object + ) + + assert status.success?, payload.inspect + assert_equal "TAG_VERIFIED", payload.fetch("status") + assert_equal "v1.2.3", payload.fetch("release_ref") + assert_equal tag_object, payload.fetch("tag_object") + assert_equal commit, payload.fetch("peeled_commit") + end + end + + def test_verify_tag_rejects_malformed_missing_lightweight_moved_and_wrong_commit_tags + with_release_repository("1.2.3") do |root, commit| + git(root, "tag", "v1.2.3") + lightweight_object = git(root, "rev-parse", "refs/tags/v1.2.3") + + payload, status = verify_tag(root, "release-1.2.3", commit, lightweight_object) + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "exact vX.Y.Z" + + payload, status = verify_tag(root, "v9.9.9", commit, lightweight_object) + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "refs/tags/v9.9.9" + + payload, status = verify_tag(root, "v1.2.3", commit, lightweight_object) + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "annotated tag" + + git(root, "tag", "-d", "v1.2.3") + git(root, "tag", "-a", "v1.2.3", "-m", "replacement tag") + moved_object = git(root, "rev-parse", "refs/tags/v1.2.3") + refute_equal lightweight_object, moved_object + + payload, status = verify_tag(root, "v1.2.3", commit, lightweight_object) + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "tag moved" + + payload, status = verify_tag(root, "v1.2.3", "0" * 40, moved_object) + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "not approved commit" + end + end + + def test_verify_tag_rejects_source_ref_version_mismatch + with_release_repository("1.2.4") do |root, commit| + git(root, "tag", "-a", "v1.2.3", "-m", "mismatched release") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + + payload, status = verify_tag(root, "v1.2.3", commit, tag_object) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "does not match VERSION 1.2.4" + end + end + + def test_record_receipt_binds_exact_release_commit_and_protected_environment_approval + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + git(root, "tag", "-a", "v1.2.3", "-m", "Agent Workflows v1.2.3") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + + payload, status = run_json( + "record-receipt", + "--root", root, + "--release", "v1.2.3", + "--approved-commit", commit, + "--expected-tag-object", tag_object, + "--environment", "stable-release", + "--change-author", "feature-author", + "--release-actor", "release-operator", + "--approval-reviewer", "release-approver", + "--repository", "shakacode/agent-workflows", + "--workflow-run-id", "1234", + "--workflow-run-attempt", "2", + "--workflow-path", ".github/workflows/release.yml", + "--workflow-run-url", "https://github.com/shakacode/agent-workflows/actions/runs/1234", + "--recorded-at", "2026-08-08T12:00:00Z", + "--receipt", receipt_path + ) + assert status.success?, payload.inspect + receipt = JSON.parse(File.read(receipt_path)) + assert_equal "RECEIPT_WRITTEN", payload.fetch("status") + assert_equal "stable", receipt.fetch("channel") + assert_equal({ "suite" => "full", "exact_commit" => commit, "before_tag" => true }, receipt.fetch("verification")) + assert_equal "v1.2.3", receipt.fetch("release_ref") + assert_equal tag_object, receipt.fetch("tag_object") + assert_equal commit, receipt.fetch("peeled_commit") + assert_equal commit, receipt.dig("approval", "exact_commit") + assert_equal "stable-release", receipt.dig("approval", "environment") + assert_equal "release-approver", receipt.dig("approval", "reviewer") + assert_equal true, receipt.dig("approval", "human_non_author") + assert_equal "shakacode/agent-workflows", receipt.dig("workflow", "repository") + assert_equal 1234, receipt.dig("workflow", "run_id") + assert_equal 2, receipt.dig("workflow", "run_attempt") + assert_equal ".github/workflows/release.yml", receipt.dig("workflow", "path") + assert_equal "2026-08-08T12:00:00Z", receipt.fetch("recorded_at") + end + end + + def test_verify_receipt_rejects_a_tag_moved_after_release + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + release_path = File.join(root, "github-release.json") + run_path = File.join(root, "github-workflow-run.json") + approvals_path = File.join(root, "github-workflow-approvals.json") + git(root, "tag", "-a", "v1.2.3", "-m", "original tag") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + record_receipt(root, commit, tag_object, receipt_path) + write_github_release(release_path, receipt_path) + write_github_workflow_run(run_path, commit) + write_github_approvals(approvals_path) + + verification_args = [ + "verify-receipt", "--root", root, "--receipt", receipt_path, + "--repository", "shakacode/agent-workflows", + "--github-release", release_path, + "--github-workflow-run", run_path, + "--github-workflow-approvals", approvals_path + ] + payload, status = run_json(*verification_args) + assert status.success?, payload.inspect + assert_equal "RECEIPT_VERIFIED", payload.fetch("status") + + original_receipt = File.read(receipt_path) + [nil, { "suite" => "selected", "exact_commit" => commit, "before_tag" => true }, + { "suite" => "full", "exact_commit" => "0" * 40, "before_tag" => true }, + { "suite" => "full", "exact_commit" => commit, "before_tag" => false }].each do |verification| + receipt = JSON.parse(original_receipt) + receipt["verification"] = verification + File.write(receipt_path, JSON.generate(receipt)) + write_github_release(release_path, receipt_path) + rejected, rejected_status = run_json(*verification_args) + assert_equal 2, rejected_status.exitstatus + assert_includes rejected.fetch("reason"), "full exact-candidate verification before tagging" + end + File.write(receipt_path, original_receipt) + write_github_release(release_path, receipt_path) + + git(root, "tag", "-d", "v1.2.3") + git(root, "tag", "-a", "v1.2.3", "-m", "moved tag") + payload, status = run_json(*verification_args) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "tag moved" + end + end + + def test_verify_receipt_rejects_self_asserted_json_without_github_provenance + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + git(root, "tag", "-a", "v1.2.3", "-m", "self-asserted release") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + record_receipt(root, commit, tag_object, receipt_path) + + payload, status = run_json("verify-receipt", "--root", root, "--receipt", receipt_path) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "GitHub release provenance" + end + end + + def test_verify_receipt_rejects_a_release_asset_digest_mismatch + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + release_path = File.join(root, "github-release.json") + git(root, "tag", "-a", "v1.2.3", "-m", "digest-bound release") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + record_receipt(root, commit, tag_object, receipt_path) + File.write( + release_path, + JSON.generate( + "tag_name" => "v1.2.3", + "draft" => false, + "prerelease" => false, + "assets" => [{ + "name" => "agent-workflows-release-receipt.json", + "state" => "uploaded", + "digest" => "sha256:#{'0' * 64}", + "browser_download_url" => "https://github.com/shakacode/agent-workflows/releases/download/v1.2.3/agent-workflows-release-receipt.json" + }] + ) + ) + + payload, status = run_json( + "verify-receipt", "--root", root, "--receipt", receipt_path, + "--repository", "shakacode/agent-workflows", + "--github-release", release_path + ) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "asset digest" + refute_equal "sha256:#{Digest::SHA256.file(receipt_path).hexdigest}", "sha256:#{'0' * 64}" + end + end + + def test_verify_receipt_rejects_an_asset_without_exact_workflow_run_provenance + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + release_path = File.join(root, "github-release.json") + git(root, "tag", "-a", "v1.2.3", "-m", "workflow-bound release") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + record_receipt(root, commit, tag_object, receipt_path) + write_github_release(release_path, receipt_path) + + payload, status = run_json( + "verify-receipt", "--root", root, "--receipt", receipt_path, + "--repository", "shakacode/agent-workflows", + "--github-release", release_path + ) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "workflow run provenance" + end + end + + def test_verify_receipt_rejects_workflow_run_metadata_for_the_wrong_head + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + release_path = File.join(root, "github-release.json") + run_path = File.join(root, "github-workflow-run.json") + git(root, "tag", "-a", "v1.2.3", "-m", "exact-head release") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + record_receipt(root, commit, tag_object, receipt_path) + write_github_release(release_path, receipt_path) + write_github_workflow_run(run_path, "0" * 40) + + payload, status = run_json( + "verify-receipt", "--root", root, "--receipt", receipt_path, + "--repository", "shakacode/agent-workflows", + "--github-release", release_path, + "--github-workflow-run", run_path + ) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "workflow run metadata" + end + end + + def test_verify_receipt_rejects_matching_provenance_from_another_workflow + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + release_path = File.join(root, "github-release.json") + run_path = File.join(root, "github-workflow-run.json") + approvals_path = File.join(root, "github-workflow-approvals.json") + git(root, "tag", "-a", "v1.2.3", "-m", "workflow-bound release") + record_receipt(root, commit, git(root, "rev-parse", "refs/tags/v1.2.3"), receipt_path) + receipt = JSON.parse(File.read(receipt_path)) + receipt.fetch("workflow")["path"] = ".github/workflows/other.yml" + File.write(receipt_path, JSON.generate(receipt)) + write_github_release(release_path, receipt_path) + write_github_workflow_run(run_path, commit) + run = JSON.parse(File.read(run_path)) + run["path"] = receipt.fetch("workflow").fetch("path") + File.write(run_path, JSON.generate(run)) + write_github_approvals(approvals_path) + + payload, status = run_json( + "verify-receipt", "--root", root, "--receipt", receipt_path, + "--repository", "shakacode/agent-workflows", + "--github-release", release_path, "--github-workflow-run", run_path, + "--github-workflow-approvals", approvals_path + ) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "workflow run metadata" + end + end + + def test_verify_receipt_rejects_a_successful_run_without_approval_provenance + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + release_path = File.join(root, "github-release.json") + run_path = File.join(root, "github-workflow-run.json") + git(root, "tag", "-a", "v1.2.3", "-m", "approval-bound release") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + record_receipt(root, commit, tag_object, receipt_path) + write_github_release(release_path, receipt_path) + write_github_workflow_run(run_path, commit) + + payload, status = run_json( + "verify-receipt", "--root", root, "--receipt", receipt_path, + "--repository", "shakacode/agent-workflows", + "--github-release", release_path, + "--github-workflow-run", run_path + ) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "approval provenance" + end + end + + def test_verify_receipt_rejects_approval_history_for_the_wrong_reviewer + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + release_path = File.join(root, "github-release.json") + run_path = File.join(root, "github-workflow-run.json") + approvals_path = File.join(root, "github-workflow-approvals.json") + git(root, "tag", "-a", "v1.2.3", "-m", "reviewer-bound release") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + record_receipt(root, commit, tag_object, receipt_path) + write_github_release(release_path, receipt_path) + write_github_workflow_run(run_path, commit) + write_github_approvals(approvals_path, reviewer: "different-reviewer") + + payload, status = run_json( + "verify-receipt", "--root", root, "--receipt", receipt_path, + "--repository", "shakacode/agent-workflows", + "--github-release", release_path, + "--github-workflow-run", run_path, + "--github-workflow-approvals", approvals_path + ) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "protected-environment approval" + end + end + + def test_verify_receipt_rejects_an_asset_uploaded_by_an_untrusted_actor + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + release_path = File.join(root, "github-release.json") + run_path = File.join(root, "github-workflow-run.json") + approvals_path = File.join(root, "github-workflow-approvals.json") + git(root, "tag", "-a", "v1.2.3", "-m", "actor-bound release") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + record_receipt(root, commit, tag_object, receipt_path) + write_github_release(release_path, receipt_path, uploader: "different-actor", uploader_type: "User") + write_github_workflow_run(run_path, commit) + write_github_approvals(approvals_path) + + payload, status = run_json( + "verify-receipt", "--root", root, "--receipt", receipt_path, + "--repository", "shakacode/agent-workflows", + "--github-release", release_path, + "--github-workflow-run", run_path, + "--github-workflow-approvals", approvals_path + ) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "GitHub Actions release publisher" + end + end + + def test_verify_receipt_rejects_provenance_for_a_different_expected_repository + with_release_repository("1.2.3") do |root, commit| + receipt_path = File.join(root, "release-receipt.json") + release_path = File.join(root, "github-release.json") + run_path = File.join(root, "github-workflow-run.json") + approvals_path = File.join(root, "github-workflow-approvals.json") + git(root, "tag", "-a", "v1.2.3", "-m", "repository-bound release") + tag_object = git(root, "rev-parse", "refs/tags/v1.2.3") + record_receipt(root, commit, tag_object, receipt_path) + write_github_release(release_path, receipt_path) + write_github_workflow_run(run_path, commit) + write_github_approvals(approvals_path) + + payload, status = run_json( + "verify-receipt", "--root", root, "--receipt", receipt_path, + "--repository", "other/project", + "--github-release", release_path, + "--github-workflow-run", run_path, + "--github-workflow-approvals", approvals_path + ) + + assert_equal 2, status.exitstatus + assert_includes payload.fetch("reason"), "expected repository" + end + end + + def test_documented_bootstrap_accepts_authenticated_release_before_installing + run_documented_bootstrap("approved") do |output, status, sentinel| + assert status.success?, output + assert_path_exists sentinel + assert_includes output, "PUBLISHED_RELEASE_VERIFIED" + end + end + + def test_documented_bootstrap_rejects_unapproved_installer_before_execution + run_documented_bootstrap("missing-receipt") do |output, status, sentinel| + refute status.success?, output + refute_path_exists sentinel + end + end + + def test_documented_bootstrap_rejects_replaced_verifier_before_execution + run_documented_bootstrap("replaced-verifier") do |output, status, sentinel| + refute status.success?, output + refute_path_exists sentinel + assert_includes output, "Untrusted bootstrap verifier" + end + end + + private + + def run_documented_bootstrap(scenario) + with_release_repository("1.2.3") do |root, _initial_commit| + Dir.mktmpdir("release-bootstrap") do |temp| + FileUtils.mkdir_p(File.join(root, "bin")) + verifier = File.binread(SCRIPT) + verifier = 'File.write(ENV.fetch("QA_BOOTSTRAP_SENTINEL"), "untrusted verifier")' if scenario == "replaced-verifier" + File.write(File.join(root, "bin/agent-workflows-release"), verifier) + installer = File.join(root, "bin/install-agent-workflows") + File.write(installer, "#!/usr/bin/env bash\nset -euo pipefail\ntouch \"$QA_BOOTSTRAP_SENTINEL\"\n") + File.chmod(0o755, installer) + git(root, "add", ".") + git(root, "commit", "--quiet", "-m", "bootstrap fixture") + commit = git(root, "rev-parse", "HEAD") + git(root, "tag", "-a", "v1.2.3", "-m", "bootstrap fixture") + receipt = File.join(temp, "receipt.json") + record_receipt(root, commit, git(root, "rev-parse", "refs/tags/v1.2.3"), receipt) + write_github_release(File.join(temp, "release.json"), receipt) + write_github_workflow_run(File.join(temp, "run.json"), commit) + write_github_approvals(File.join(temp, "approvals.json")) + File.unlink(receipt) if scenario == "missing-receipt" + fake_bin = File.join(temp, "fake-bin") + FileUtils.mkdir_p(fake_bin) + curl = File.join(fake_bin, "curl") + File.write(curl, <<~'RUBY') + #!/usr/bin/env ruby + require "fileutils" + url = ARGV.last + name = if url.include?("/releases/download/") + "receipt.json" + elsif url.include?("/releases/tags/") + "release.json" + elsif url.include?("/attempts/") + "run.json" + elsif url.end_with?("/approvals") + "approvals.json" + else + abort "unexpected fixture URL" + end + source = File.join(ENV.fetch("QA_BOOTSTRAP_EVIDENCE"), name) + exit 22 unless File.file?(source) + FileUtils.cp(source, ARGV.fetch(ARGV.index("--output") + 1)) + RUBY + File.chmod(0o755, curl) + guide = File.read(File.expand_path("../docs/release-channel.md", __dir__)) + commands = guide.match(/```bash\n(.*?)```/m).captures.first + commands = commands.sub("release=vX.Y.Z", "release=v1.2.3") + .sub('source="$HOME/src/agent-workflows"', "source=#{Shellwords.escape(File.join(temp, 'fresh source'))}") + .sub("https://github.com/shakacode/agent-workflows", Shellwords.escape(root)) + sentinel = File.join(temp, "candidate-executed") + env = { + "PATH" => "#{fake_bin}:#{ENV.fetch('PATH')}", "QA_BOOTSTRAP_SENTINEL" => sentinel, + "QA_BOOTSTRAP_EVIDENCE" => temp, "GH_TOKEN" => nil, "GITHUB_TOKEN" => nil, + "BASH_ENV" => nil, "ENV" => nil, "RUBYOPT" => nil + } + output, status = Open3.capture2e(env, "bash", "--noprofile", "--norc", "-c", commands) + yield output, status, sentinel + end + end + end + + def with_release_root + Dir.mktmpdir("agent-workflows-release-test") do |root| + FileUtils.mkdir_p(File.join(root, ".claude-plugin")) + FileUtils.mkdir_p(File.join(root, ".codex-plugin")) + yield root + end + end + + def write_versions(root, version) + File.write(File.join(root, "VERSION"), "#{version}\n") + File.write( + File.join(root, ".claude-plugin", "plugin.json"), + "#{JSON.generate('name' => 'scw', 'version' => version)}\n" + ) + File.write( + File.join(root, ".codex-plugin", "plugin.json"), + "#{JSON.generate('name' => 'scw', 'version' => version)}\n" + ) + end + + def run_json(*args) + output, status = Open3.capture2e("ruby", SCRIPT, *args, "--json") + [JSON.parse(output), status] + end + + def verify_tag(root, release, approved_commit, expected_tag_object) + run_json( + "verify-tag", + "--root", root, + "--release", release, + "--approved-commit", approved_commit, + "--expected-tag-object", expected_tag_object + ) + end + + def record_receipt(root, commit, tag_object, receipt_path) + payload, status = run_json( + "record-receipt", + "--root", root, + "--release", "v1.2.3", + "--approved-commit", commit, + "--expected-tag-object", tag_object, + "--environment", "stable-release", + "--change-author", "feature-author", + "--release-actor", "release-operator", + "--approval-reviewer", "release-approver", + "--repository", "shakacode/agent-workflows", + "--workflow-run-id", "1234", + "--workflow-run-attempt", "1", + "--workflow-path", ".github/workflows/release.yml", + "--workflow-run-url", "https://github.com/shakacode/agent-workflows/actions/runs/1234", + "--recorded-at", "2026-08-08T12:00:00Z", + "--receipt", receipt_path + ) + raise payload.inspect unless status.success? + end + + def write_github_release(path, receipt_path, uploader: "github-actions[bot]", uploader_type: "Bot") + File.write( + path, + JSON.generate( + "tag_name" => "v1.2.3", + "draft" => false, + "prerelease" => false, + "html_url" => "https://github.com/shakacode/agent-workflows/releases/tag/v1.2.3", + "author" => { "login" => "github-actions[bot]", "type" => "Bot" }, + "assets" => [{ + "name" => "agent-workflows-release-receipt.json", + "state" => "uploaded", + "size" => File.size(receipt_path), + "digest" => "sha256:#{Digest::SHA256.file(receipt_path).hexdigest}", + "browser_download_url" => "https://github.com/shakacode/agent-workflows/releases/download/v1.2.3/agent-workflows-release-receipt.json", + "uploader" => { "login" => uploader, "type" => uploader_type } + }] + ) + ) + end + + def write_github_workflow_run(path, head_sha) + File.write( + path, + JSON.generate( + "id" => 1234, + "run_attempt" => 1, + "path" => ".github/workflows/release.yml", + "event" => "workflow_dispatch", + "status" => "completed", + "conclusion" => "success", + "html_url" => "https://github.com/shakacode/agent-workflows/actions/runs/1234", + "head_branch" => "main", + "head_sha" => head_sha, + "actor" => { "login" => "release-operator", "type" => "User" }, + "repository" => { "full_name" => "shakacode/agent-workflows", "private" => false } + ) + ) + end + + def write_github_approvals(path, reviewer: "release-approver") + File.write( + path, + JSON.generate( + [{ + "state" => "approved", + "environments" => [{ "name" => "stable-release" }], + "user" => { "login" => reviewer, "type" => "User" } + }] + ) + ) + end + + def with_release_repository(version) + with_release_root do |root| + write_versions(root, version) + git(root, "init", "--quiet") + git(root, "config", "user.email", "release-test@example.com") + git(root, "config", "user.name", "Release Test") + git(root, "add", ".") + git(root, "commit", "--quiet", "-m", "release fixture") + yield root, git(root, "rev-parse", "HEAD") + end + end + + def git(root, *args) + output, status = Open3.capture2e("git", "-C", root, *args) + raise output unless status.success? + + output.strip + end +end diff --git a/bin/agent-workflows-status b/bin/agent-workflows-status index 6f4ac9486..3e1cb2759 100755 --- a/bin/agent-workflows-status +++ b/bin/agent-workflows-status @@ -2,9 +2,11 @@ # frozen_string_literal: true require "json" +require "digest" require "optparse" require "open3" require "rbconfig" +require "tmpdir" load File.expand_path("agent-workflows-delivery-state", __dir__) @@ -117,18 +119,72 @@ module AgentWorkflowsStatus branch.empty? ? "origin/main" : "origin/#{branch}" end - def available_revision(source, metadata, fetch:) + def verify_stable_release(source, metadata, release_ref, fetch:) + unless release_ref.is_a?(String) && release_ref.match?(/\Av\d+\.\d+\.\d+\z/) + return [nil, nil, nil, "stable status requires an exact vX.Y.Z release"] + end + + if fetch + _, fetch_error = git_output(source, "fetch", "--quiet", "--prune", "--tags", "origin") + return [nil, nil, nil, "git fetch failed: #{fetch_error}"] if fetch_error + end + + tag_ref = "refs/tags/#{release_ref}" + tag_object, tag_error = git_output(source, "rev-parse", "--verify", tag_ref) + return [nil, nil, nil, "missing stable release tag #{release_ref}: #{tag_error}"] if tag_error + + expected_tag_object = release_ref == metadata["release_ref"] ? metadata["tag_object"].to_s : tag_object + if expected_tag_object.empty? + return [nil, nil, nil, "installed stable metadata is missing tag_object"] + end + if tag_object != expected_tag_object + return [nil, nil, nil, "tag moved: expected #{expected_tag_object}, found #{tag_object}"] + end + + helper = File.expand_path("agent-workflows-release", __dir__) + output, status = Open3.capture2e( + RbConfig.ruby, helper, "verify-tag", "--root", source, "--release", release_ref, + "--approved-commit", git_output(source, "rev-parse", "--verify", "#{tag_ref}^{commit}").first.to_s, + "--expected-tag-object", expected_tag_object, "--json" + ) + unless status.success? + reason = JSON.parse(output).fetch("reason") + return [nil, nil, nil, reason] + end + + verified = JSON.parse(output) + if release_ref != metadata["release_ref"] + output, status = Open3.capture2e( + RbConfig.ruby, helper, "verify-published", "--root", source, "--release", release_ref, + "--approved-commit", verified.fetch("peeled_commit"), "--expected-tag-object", tag_object, + "--repository", "shakacode/agent-workflows", "--json" + ) + return [nil, nil, nil, JSON.parse(output).fetch("reason")] unless status.success? + end + [verified.fetch("peeled_commit"), verified.fetch("version"), tag_object, nil] + rescue JSON::ParserError, SystemCallError => e + [nil, nil, nil, "stable release verification failed: #{e.message}"] + end + + def available_revision(source, metadata, fetch:, channel:, release_ref:) return [nil, "missing source root: #{source}"] unless File.directory?(source) + if channel == "stable" + revision, version, tag_object, error = verify_stable_release(source, metadata, release_ref, fetch:) + return [nil, error, nil, nil] if error + + return [revision, nil, version, tag_object] + end + unless File.directory?(File.join(source, ".git")) version = version_for(source) - return ["version:#{version}", nil] + return ["version:#{version}", nil, version, nil] end ref = if fetch _, fetch_error = git_output(source, "fetch", "--quiet", "--prune", "origin") - return [nil, "git fetch failed: #{fetch_error}"] if fetch_error + return [nil, "git fetch failed: #{fetch_error}", nil, nil] if fetch_error remote_ref_for(source, metadata) else @@ -136,9 +192,9 @@ module AgentWorkflowsStatus end revision, error = git_output(source, "rev-parse", ref) - return [nil, "git rev-parse #{ref} failed: #{error}"] if error + return [nil, "git rev-parse #{ref} failed: #{error}", nil, nil] if error - [revision, nil] + [revision, nil, version_for(source), nil] end def short_revision(value) @@ -169,7 +225,234 @@ module AgentWorkflowsStatus { "superpowers" => AgentWorkflowsDeliveryState.superpowers_state(host, target) } end - def status_payload(status, target:, source:, host:, metadata: {}, available_revision: nil, checked_remote: false, reason: nil, delivery_state: nil) + def with_superpowers_delivery_state(delivery, host, target) + return delivery if delivery&.key?("superpowers") + + superpowers = superpowers_delivery_state(host, target) + superpowers ? (delivery || {}).merge(superpowers) : delivery + end + + def stable_delivery_state(host:, target:, source:, revision:, delivery_mode:, metadata:) + Dir.mktmpdir("agent-workflows-stable-status") do |materialized| + archive = File.join(materialized, "release.tar") + output, status = Open3.capture2e("git", "-C", source, "archive", "--format=tar", "--output", archive, revision) + raise "git archive failed: #{output.force_encoding('UTF-8').scrub.strip}" unless status.success? + + output, status = Open3.capture2e("tar", "-xf", archive, "-C", materialized) + raise "release extraction failed: #{output.force_encoding('UTF-8').scrub.strip}" unless status.success? + + File.delete(archive) + inventory_error = stable_release_inventory_error(materialized, metadata) + return [nil, inventory_error] if inventory_error + + if metadata.fetch("delivery_mode", "flat") == "flat" + released_skills = File.join(materialized, "skills") + installed_skills = File.join(target, "skills") + unless File.directory?(installed_skills) && !File.symlink?(installed_skills) + return [nil, "installed skill directory is missing or is a symlink"] + end + + Dir.children(released_skills).each do |name| + released = File.join(released_skills, name) + next unless File.directory?(released) && !File.symlink?(released) + + installed = File.join(installed_skills, name) + unless File.directory?(installed) && !File.symlink?(installed) + return [nil, "installed release skill is missing or is a symlink: #{name}"] + end + end + end + + license = File.join(materialized, "LICENSE") + return [nil, "release license is not a regular file"] unless File.file?(license) && !File.symlink?(license) + + license_error = stable_managed_surface_error( + target, { "release_license" => { "LICENSE" => Digest::SHA256.file(license).hexdigest } }, + fields: { "release license" => %w[release_license .] } + ) + return [nil, license_error] if license_error + + doctor = File.join(materialized, "bin/agent_doctor") + if File.exist?(doctor) || File.symlink?(doctor) + unless File.directory?(doctor) && !File.symlink?(doctor) + return [nil, "release doctor runtime is not a regular directory"] + end + + installed_bin = File.join(target, "bin") + unless File.directory?(installed_bin) && !File.symlink?(installed_bin) + return [nil, "installed doctor runtime ancestor is not a regular directory"] + end + + _, _, matches = Open3.capture3( + RbConfig.ruby, File.join(doctor, "install_ownership.rb"), "compare", doctor, File.join(target, "bin/agent_doctor") + ) + return [nil, "installed doctor runtime differs from verified release"] unless matches.success? + end + if metadata["delivery_mode"] == "plugin-companion" + scanner = File.join(materialized, "skills/secure-github-actions/lib/secure_github_actions_scanner.rb") + return [nil, "release scanner is not a regular file"] unless File.file?(scanner) && !File.symlink?(scanner) + + scanner_error = stable_managed_surface_error( + target, { "release_scanner" => { "secure_github_actions_scanner.rb" => Digest::SHA256.file(scanner).hexdigest } }, + fields: { "companion scanner" => %w[release_scanner lib/agent-workflows] } + ) + return [nil, scanner_error] if scanner_error + end + + workflows = AgentWorkflowsDeliveryState.filesystem_entries(File.join(materialized, "workflows")) || {} + fingerprints = workflows.filter_map do |name, path| + next if File.directory?(path) && !File.symlink?(path) + unless File.file?(path) && !File.symlink?(path) + return [nil, "release workflow is not a regular file: #{name}"] + end + + [name, Digest::SHA256.file(path).hexdigest] + end.to_h + unless fingerprints.empty? + installed = File.join(target, "workflows") + unless File.directory?(installed) && !File.symlink?(installed) + return [nil, "installed workflow directory is missing or is a symlink"] + end + end + workflow_error = stable_managed_surface_error( + target, { "release_workflows" => fingerprints }, + fields: { "managed workflow" => %w[release_workflows workflows] } + ) + return [nil, workflow_error] if workflow_error + + delivery_state(host:, target:, source: materialized, delivery_mode:) + end + end + + def stable_release_inventory_error(release_root, metadata) + installer = File.join(release_root, "bin/install-agent-workflows") + return "release inventory provider is missing or unsafe" unless File.file?(installer) && !File.symlink?(installer) + + output, error, status = Open3.capture3( + { "BASH_ENV" => nil, "ENV" => nil, "RUBYOPT" => nil }, + "bash", "--noprofile", "--norc", installer, "--print-install-inventory" + ) + return "release inventory provider failed: #{error.force_encoding('UTF-8').scrub.strip}" unless status.success? + + inventory = JSON.parse(output.force_encoding("UTF-8").scrub) + unless inventory.is_a?(Hash) && inventory["version"] == 1 + return "release inventory is invalid" + end + + expected = { + "managed_bin_helper_copy_fingerprints" => inventory["bin_helpers"], + "managed_pack_doc_copy_fingerprints" => inventory["pack_docs"], + "managed_pack_root_copy_fingerprints" => ["THIRD_PARTY-NOTICES.md"] + } + expected.each do |field, names| + unless names.is_a?(Array) && names.all? { |name| name.is_a?(String) } && names.uniq == names && + metadata[field].is_a?(Hash) && metadata[field].keys.sort == names.sort + return "installed stable metadata inventory does not match release: #{field}" + end + end + error = stable_managed_surface_error(release_root, metadata) + error && "release inventory fingerprint mismatch: #{error}" + rescue JSON::ParserError, SystemCallError => e + "release inventory could not be checked: #{e.message}" + end + + def managed_surface_stat_identity(stat) + [ + stat.ftype, + stat.dev, + stat.ino, + stat.mode, + stat.size, + stat.mtime.to_i, + stat.mtime.nsec, + stat.ctime.to_i, + stat.ctime.nsec + ] + end + + def stable_managed_surface_error(target, metadata, fields: nil) + fields ||= { + "managed helper" => %w[managed_bin_helper_copy_fingerprints bin], + "managed pack root file" => %w[managed_pack_root_copy_fingerprints .], + "managed pack document" => %w[managed_pack_doc_copy_fingerprints docs] + } + fields.each do |label, (field, directory)| + fingerprints = metadata[field] + return "installed stable metadata is missing #{field}" unless fingerprints.is_a?(Hash) + + fingerprints.each do |name, expected| + valid_name = if %w[bin .].include?(directory) + name.is_a?(String) && name.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]*\z/) + else + segments = name.is_a?(String) ? name.split("/", -1) : [] + name.is_a?(String) && !name.empty? && !name.start_with?("/") && + !name.match?(/[\x00-\x1f\x7f]/) && + segments.none? { |segment| segment.empty? || segment == "." || segment == ".." } + end + return "installed stable metadata has invalid #{label} path" unless valid_name + unless expected.is_a?(String) && expected.match?(/\A[0-9a-f]{64}\z/i) + return "installed stable metadata has invalid fingerprint for #{name}" + end + + path = File.join(target, directory, name) + ancestor = target + ancestors = (directory.split("/") + name.split("/")[0...-1]).reject { |part| part == "." }.map do |part| + ancestor = File.join(ancestor, part) + stat = File.lstat(ancestor) + return "installed #{label} ancestor is not a regular directory: #{name}" unless stat.directory? && !stat.symlink? + + [ancestor, stat.dev, stat.ino] + end + file = nil + begin + stat = File.lstat(path) + rescue Errno::ENOENT, Errno::ENOTDIR + return "installed #{label} is missing: #{name}" + end + unless stat.file? && !stat.symlink? && File.const_defined?(:NOFOLLOW) + return "installed #{label} is not a safely readable regular file: #{name}" + end + + begin + file = File.open(path, File::RDONLY | File::NONBLOCK | File::NOFOLLOW) + opened_stat = file.stat + opened_identity = managed_surface_stat_identity(opened_stat) + unless opened_stat.file? && opened_identity == managed_surface_stat_identity(stat) + return "installed #{label} changed during check: #{name}" + end + if directory == "bin" && (opened_stat.mode & 0o111).zero? + return "installed #{label} executable mode is missing: #{name}" + end + + digest = Digest::SHA256.new + while (chunk = file.read(64 * 1024)) + digest.update(chunk) + end + post_hash_stat = file.stat + final_path_stat = File.lstat(path) + unless post_hash_stat.file? && managed_surface_stat_identity(post_hash_stat) == opened_identity && + final_path_stat.file? && !final_path_stat.symlink? && + managed_surface_stat_identity(final_path_stat) == opened_identity + return "installed #{label} changed during check: #{name}" + end + if directory == "bin" && ((post_hash_stat.mode & 0o111).zero? || (final_path_stat.mode & 0o111).zero?) + return "installed #{label} executable mode is missing: #{name}" + end + unless ancestors.all? { |parent, dev, ino| (current = File.lstat(parent)).directory? && !current.symlink? && current.dev == dev && current.ino == ino } + return "installed #{label} ancestor changed during check: #{name}" + end + return "installed #{label} fingerprint changed: #{name}" unless digest.hexdigest == expected.downcase + ensure + file&.close + end + end + end + nil + rescue SystemCallError => e + "installed stable managed-surface check failed: #{e.message}" + end + + def status_payload(status, target:, source:, host:, metadata: {}, available_revision: nil, available_version: nil, available_release_ref: nil, checked_remote: false, reason: nil, delivery_state: nil) payload = { "status" => status, "host" => host, @@ -177,8 +460,12 @@ module AgentWorkflowsStatus "source" => source, "installed_version" => metadata["version"], "installed_revision" => metadata["source_revision"], - "available_version" => source && File.directory?(source) ? version_for(source) : nil, + "available_version" => available_version || (source && File.directory?(source) ? version_for(source) : nil), "available_revision" => available_revision, + "channel" => metadata["channel"] || "development", + "release_ref" => metadata["release_ref"], + "available_release_ref" => available_release_ref, + "exact_commit" => metadata["source_revision"], "checked_remote" => checked_remote, "reason" => reason, "guidance" => delivery_state&.fetch("guidance", nil), @@ -193,11 +480,11 @@ module AgentWorkflowsStatus def text_for(payload) line = case payload.fetch("status") when "UP_TO_DATE" - "UP_TO_DATE version=#{payload['installed_version']} revision=#{short_revision(payload['installed_revision'])} delivery_mode=#{payload['delivery_mode']} target=#{payload['target']}" + "UP_TO_DATE channel=#{payload['channel']} release_ref=#{payload['release_ref'] || 'none'} version=#{payload['installed_version']} exact_commit=#{payload['exact_commit']} delivery_mode=#{payload['delivery_mode']} target=#{payload['target']}" when "UPGRADE_AVAILABLE" installed = "#{payload['installed_version']}@#{short_revision(payload['installed_revision'])}" available = "#{payload['available_version']}@#{short_revision(payload['available_revision'])}" - "UPGRADE_AVAILABLE #{installed} #{available} delivery_mode=#{payload['delivery_mode']} target=#{payload['target']}" + "UPGRADE_AVAILABLE channel=#{payload['channel']} release_ref=#{payload['release_ref'] || 'none'} #{installed} #{available} exact_commit=#{payload['exact_commit']} delivery_mode=#{payload['delivery_mode']} target=#{payload['target']}" when "NOT_INSTALLED" "NOT_INSTALLED target=#{payload['target']}" when "CHECK_FAILED" @@ -257,9 +544,60 @@ module AgentWorkflowsStatus ) end + installed_channel = metadata.fetch("channel", "development") + requested_channel = options[:channel] || installed_channel + unless %w[stable development].include?(requested_channel) + raise ArgumentError, "--channel must be stable or development, got: #{requested_channel}" + end + if requested_channel != installed_channel + raise ArgumentError, "refusing channel change from #{installed_channel} to #{requested_channel}" + end + + requested_release = options[:release] || metadata["release_ref"] + if requested_channel == "stable" && requested_release.to_s.empty? + raise ArgumentError, "stable status requires --release or installed release_ref metadata" + end + if requested_channel == "development" && options[:release] + raise ArgumentError, "development channel cannot use --release" + end + source = File.expand_path(options[:source] || metadata["source"].to_s) delivery_mode = requested_delivery_mode || metadata.fetch("delivery_mode", "flat") - delivery, delivery_error = delivery_state(host:, target:, source:, delivery_mode:) + revision, error, available_version, = available_revision( + source, metadata, fetch: options.fetch(:fetch), channel: requested_channel, release_ref: requested_release + ) + if error + return status_payload( + "CHECK_FAILED", + target:, + source:, + host:, + metadata:, + checked_remote: options.fetch(:fetch), + reason: error, + delivery_state: superpowers_delivery_state(host, target) + ) + end + integrity_revision = revision + integrity_version = available_version + if requested_channel == "stable" + if requested_release != metadata["release_ref"] + integrity_revision, integrity_version, _, integrity_error = verify_stable_release(source, metadata, metadata["release_ref"], fetch: false) + raise ArgumentError, integrity_error if integrity_error + end + unless metadata["source_revision"] == integrity_revision + raise ArgumentError, "installed stable metadata revision does not match its release tag" + end + unless metadata["version"] == integrity_version + raise ArgumentError, "installed stable metadata version does not match its release tag" + end + end + delivery, delivery_error = if requested_channel == "stable" + stable_delivery_state(host:, target:, source:, revision: integrity_revision, delivery_mode:, metadata:) + else + delivery_state(host:, target:, source:, delivery_mode:) + end + delivery = with_superpowers_delivery_state(delivery, host, target) if delivery_error delivery ||= {} delivery.merge!(superpowers_delivery_state(host, target) || {}) unless delivery.key?("superpowers") @@ -273,23 +611,20 @@ module AgentWorkflowsStatus delivery_state: delivery ) end - revision, error = available_revision(source, metadata, fetch: options.fetch(:fetch)) - if error + if requested_channel == "stable" && (surface_error = stable_managed_surface_error(target, metadata)) return status_payload( "CHECK_FAILED", target:, source:, host:, metadata:, - checked_remote: options.fetch(:fetch), - reason: error, + reason: surface_error, delivery_state: delivery ) end installed_revision = metadata["source_revision"].to_s installed_version = metadata["version"].to_s - available_version = version_for(source) status = if !installed_revision.empty? && installed_revision == revision "UP_TO_DATE" elsif revision.to_s.start_with?("version:") && installed_version == available_version @@ -305,6 +640,8 @@ module AgentWorkflowsStatus host:, metadata:, available_revision: revision, + available_version:, + available_release_ref: requested_release, checked_remote: options.fetch(:fetch), delivery_state: delivery ) @@ -314,9 +651,9 @@ module AgentWorkflowsStatus end if $PROGRAM_NAME == __FILE__ - options = { host: "codex", target: nil, source: nil, delivery_mode: nil, fetch: false, json: false } + options = { host: "codex", target: nil, source: nil, delivery_mode: nil, channel: nil, release: nil, fetch: false, json: false } parser = OptionParser.new do |opts| - opts.banner = "Usage: agent-workflows-status [--host codex|claude|auto] [--target DIR] [--source DIR] [--delivery-mode flat|plugin-companion] [--fetch] [--json]" + opts.banner = "Usage: agent-workflows-status [--host codex|claude|auto] [--target DIR] [--source DIR] [--delivery-mode flat|plugin-companion] [--channel stable|development] [--release vX.Y.Z] [--fetch] [--json]" opts.on( "--host HOST", "Agent host: codex, claude, or auto (unmarked explicit targets default to codex)" @@ -326,6 +663,8 @@ if $PROGRAM_NAME == __FILE__ opts.on("--delivery-mode MODE", "Evaluate compatibility for flat or plugin-companion") do |value| options[:delivery_mode] = value end + opts.on("--channel CHANNEL", "Installed release channel: stable or development") { |value| options[:channel] = value } + opts.on("--release TAG", "Exact stable release to inspect") { |value| options[:release] = value } opts.on("--fetch", "Fetch origin before checking the source revision") { options[:fetch] = true } opts.on("--json", "Print JSON output") { options[:json] = true } opts.on("-h", "--help", "Show help") do diff --git a/bin/agent-workflows-status-test.rb b/bin/agent-workflows-status-test.rb index 76f6965e4..86a695edc 100755 --- a/bin/agent-workflows-status-test.rb +++ b/bin/agent-workflows-status-test.rb @@ -5,13 +5,16 @@ # Run with: ruby bin/agent-workflows-status-test.rb require "fileutils" +require "digest" require "json" require "minitest/autorun" require "open3" require "rbconfig" +require "shellwords" require "tmpdir" SCRIPT = File.expand_path("agent-workflows-status", __dir__) +load SCRIPT class AgentWorkflowsStatusTest < Minitest::Test def setup @@ -225,6 +228,287 @@ def test_claude_up_to_date_json_omits_superpowers_diagnostic end end + def test_stable_status_reports_release_identity_and_rejects_a_moved_tag + Dir.mktmpdir("agent-workflows-status-test") do |target| + Dir.mktmpdir("agent-workflows-status-source") do |source| + FileUtils.mkdir_p(File.join(source, ".claude-plugin")) + FileUtils.mkdir_p(File.join(source, ".codex-plugin")) + FileUtils.mkdir_p(File.join(source, "skills/example")) + FileUtils.mkdir_p(File.join(target, "skills/example")) + FileUtils.mkdir_p(File.join(source, "workflows")) + FileUtils.mkdir_p(File.join(target, "workflows")) + workflow = File.join(target, "workflows/release.md") + File.write(File.join(source, "workflows/release.md"), "stable workflow\n") + File.write(workflow, "stable workflow\n") + File.write(File.join(target, "workflows/personal.md"), "unrelated workflow\n") + File.write(File.join(source, "VERSION"), "1.2.3\n") + File.write(File.join(source, "skills/example/SKILL.md"), "stable skill\n") + File.write(File.join(target, "skills/example/SKILL.md"), "stable skill\n") + File.write(File.join(source, ".claude-plugin/plugin.json"), "{\"version\":\"1.2.3\"}\n") + File.write(File.join(source, ".codex-plugin/plugin.json"), "{\"version\":\"1.2.3\"}\n") + FileUtils.mkdir_p(File.join(source, "bin")) + [source, target].each do |root| + FileUtils.mkdir_p(File.join(root, "docs")) + File.write(File.join(root, "docs/café.md"), "unicode document name\n") + end + File.write(File.join(source, "bin/install-agent-workflows"), "echo incidental-inventory-warning >&2\nprintf '%s\\n' '#{JSON.generate(version: 1, bin_helpers: [], pack_docs: ['café.md'])}'\n") + File.write(File.join(source, "THIRD_PARTY-NOTICES.md"), "release notices\n") + FileUtils.cp(File.join(source, "THIRD_PARTY-NOTICES.md"), File.join(target, "THIRD_PARTY-NOTICES.md")) + [source, target].each { |root| File.write(File.join(root, "LICENSE"), "release license\n") } + system("git", "-C", source, "init", "--quiet", exception: true) + system("git", "-C", source, "config", "user.email", "status-test@example.com", exception: true) + system("git", "-C", source, "config", "user.name", "Status Test", exception: true) + system("git", "-C", source, "add", ".", exception: true) + system("git", "-C", source, "commit", "--quiet", "-m", "stable release", exception: true) + commit = `git -C #{Shellwords.escape(source)} rev-parse HEAD`.strip + system("git", "-C", source, "tag", "-a", "v1.2.3", "-m", "stable release", exception: true) + tag_object = `git -C #{Shellwords.escape(source)} rev-parse refs/tags/v1.2.3`.strip + File.write(File.join(source, "workflows/release.md"), "development workflow\n") + File.write(File.join(source, "VERSION"), "1.2.4\n") + File.write(File.join(source, ".claude-plugin/plugin.json"), "{\"version\":\"1.2.4\"}\n") + File.write(File.join(source, ".codex-plugin/plugin.json"), "{\"version\":\"1.2.4\"}\n") + system("git", "-C", source, "add", ".", exception: true) + system("git", "-C", source, "commit", "--quiet", "-m", "development after release", exception: true) + write_metadata( + target, + "version" => "1.2.3", + "source" => source, + "source_revision" => commit, + "delivery_mode" => "flat", + "channel" => "stable", + "release_ref" => "v1.2.3", + "tag_object" => tag_object, + "managed_bin_helper_copy_fingerprints" => {}, + "managed_pack_root_copy_fingerprints" => { "THIRD_PARTY-NOTICES.md" => Digest::SHA256.file(File.join(target, "THIRD_PARTY-NOTICES.md")).hexdigest }, + "managed_pack_doc_copy_fingerprints" => { "café.md" => Digest::SHA256.file(File.join(target, "docs/café.md")).hexdigest } + ) + + out, status = run_status( + { "LANG" => "C", "LC_ALL" => "C" }, "--target", target, "--host", "claude", "--source", source, + "--channel", "stable", "--release", "v1.2.3", "--json" + ) + payload = JSON.parse(out) + + assert_equal 0, status.exitstatus, out + assert_equal "UP_TO_DATE", payload.fetch("status") + assert_equal "stable", payload.fetch("channel") + assert_equal "v1.2.3", payload.fetch("release_ref") + assert_equal commit, payload.fetch("exact_commit") + assert_equal "1.2.3", payload.fetch("available_version") + + metadata_path = File.join(target, ".agent-workflows-install.json") + original_metadata = File.binread(metadata_path) + invalid_metadata = JSON.parse(original_metadata) + invalid_metadata["release_ref"] = 123 + File.write(metadata_path, JSON.generate(invalid_metadata)) + invalid_out, invalid_status = run_status({}, "--target", target, "--host", "claude", "--source", source, "--json") + assert_equal 3, invalid_status.exitstatus, invalid_out + assert_equal "CHECK_FAILED", JSON.parse(invalid_out).fetch("status") + File.write(metadata_path, original_metadata) + + out, status = run_status( + { "QA_SUPERPOWERS_STATE" => "active", "QA_SUPERPOWERS_MARKETPLACE" => "superpowers-dev" }, + "--target", target, "--host", "codex", "--source", source, + "--channel", "stable", "--release", "v1.2.3", "--json" + ) + payload = JSON.parse(out) + + assert_equal 0, status.exitstatus, out + assert_equal "stable", payload.fetch("channel") + assert_equal commit, payload.fetch("exact_commit") + assert_equal "active", payload.dig("superpowers", "state") + + identical_workflow = File.join(source, "identical-workflow.md") + File.write(identical_workflow, "stable workflow\n") + { "changed" => "fingerprint changed", "missing" => "is missing", + "symlink" => "not a safely readable regular file" }.each do |mutation, expected_reason| + FileUtils.rm_f(workflow) + File.write(workflow, "changed workflow\n") if mutation == "changed" + File.symlink(identical_workflow, workflow) if mutation == "symlink" + out, status = run_status({}, "--target", target, "--host", "claude", "--source", source, "--json") + assert_equal 3, status.exitstatus, "#{mutation}: #{out}" + assert_includes JSON.parse(out).fetch("reason"), "release.md" + assert_includes JSON.parse(out).fetch("reason"), expected_reason + FileUtils.rm_f(workflow) + File.write(workflow, "stable workflow\n") + end + + installed_workflows = File.join(target, "workflows") + saved_workflows = File.join(target, "saved-workflows") + FileUtils.mv(installed_workflows, saved_workflows) + %w[missing symlink].each do |mutation| + File.symlink(saved_workflows, installed_workflows) if mutation == "symlink" + out, status = run_status({}, "--target", target, "--host", "claude", "--source", source, "--json") + assert_equal 3, status.exitstatus, "#{mutation} directory: #{out}" + assert_includes JSON.parse(out).fetch("reason"), "installed workflow directory" + end + FileUtils.rm_f(installed_workflows) + FileUtils.mv(saved_workflows, installed_workflows) + + system("git", "-C", source, "tag", "-d", "v1.2.3", out: File::NULL, exception: true) + system("git", "-C", source, "tag", "-a", "v1.2.3", "-m", "moved release", exception: true) + out, status = run_status({}, "--target", target, "--host", "claude", "--source", source, "--json") + + assert_equal 3, status.exitstatus, out + assert_includes JSON.parse(out).fetch("reason"), "tag moved" + end + end + end + + def test_stable_status_fails_closed_when_recorded_managed_files_are_missing_changed_or_not_executable + Dir.mktmpdir("agent-workflows-status-test") do |target| + Dir.mktmpdir("agent-workflows-status-source") do |source| + FileUtils.mkdir_p(File.join(source, ".claude-plugin")) + FileUtils.mkdir_p(File.join(source, ".codex-plugin")) + FileUtils.mkdir_p(File.join(source, "skills/example")) + FileUtils.mkdir_p(File.join(source, "bin")) + FileUtils.mkdir_p(File.join(source, "docs")) + FileUtils.mkdir_p(File.join(target, "skills/example")) + FileUtils.mkdir_p(File.join(target, "bin")) + FileUtils.mkdir_p(File.join(target, "docs")) + File.write(File.join(source, "VERSION"), "1.2.3\n") + File.write(File.join(source, "skills/example/SKILL.md"), "stable skill\n") + File.write(File.join(target, "skills/example/SKILL.md"), "stable skill\n") + File.write(File.join(source, ".claude-plugin/plugin.json"), "{\"version\":\"1.2.3\"}\n") + File.write(File.join(source, ".codex-plugin/plugin.json"), "{\"version\":\"1.2.3\"}\n") + helper = File.join(target, "bin/release-helper") + doc = File.join(target, "docs/release-doc.md") + root_file = File.join(target, "THIRD_PARTY-NOTICES.md") + File.write(File.join(source, "THIRD_PARTY-NOTICES.md"), "release notices\n") + FileUtils.cp(File.join(source, "THIRD_PARTY-NOTICES.md"), root_file) + [source, target].each { |root| File.write(File.join(root, "LICENSE"), "release license\n") } + File.write(File.join(source, "bin/release-helper"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(source, "bin/release-helper")) + FileUtils.cp(File.join(source, "bin/release-helper"), helper) + FileUtils.chmod(0o755, helper) + File.write(File.join(source, "docs/release-doc.md"), "stable doc\n") + FileUtils.cp(File.join(source, "docs/release-doc.md"), doc) + File.write(File.join(source, "bin/install-agent-workflows"), "printf '%s\\n' '#{JSON.generate(version: 1, bin_helpers: ['release-helper'], pack_docs: ['release-doc.md'])}'\n") + system("git", "-C", source, "init", "--quiet", exception: true) + system("git", "-C", source, "config", "user.email", "status-test@example.com", exception: true) + system("git", "-C", source, "config", "user.name", "Status Test", exception: true) + system("git", "-C", source, "add", ".", exception: true) + system("git", "-C", source, "commit", "--quiet", "-m", "stable release", exception: true) + commit = `git -C #{Shellwords.escape(source)} rev-parse HEAD`.strip + system("git", "-C", source, "tag", "-a", "v1.2.3", "-m", "stable release", exception: true) + tag_object = `git -C #{Shellwords.escape(source)} rev-parse refs/tags/v1.2.3`.strip + write_metadata( + target, + "version" => "1.2.3", + "source" => source, + "source_revision" => commit, + "delivery_mode" => "flat", + "channel" => "stable", + "release_ref" => "v1.2.3", + "tag_object" => tag_object, + "managed_bin_helper_copy_fingerprints" => { + "release-helper" => Digest::SHA256.file(helper).hexdigest + }, + "managed_pack_root_copy_fingerprints" => { + "THIRD_PARTY-NOTICES.md" => Digest::SHA256.file(root_file).hexdigest + }, + "managed_pack_doc_copy_fingerprints" => { + "release-doc.md" => Digest::SHA256.file(doc).hexdigest + } + ) + + FileUtils.rm_f(helper) + missing_out, missing_status = run_status( + {}, "--target", target, "--host", "claude", "--source", source, + "--channel", "stable", "--release", "v1.2.3", "--json" + ) + FileUtils.cp(File.join(source, "bin/release-helper"), helper) + File.write(doc, "locally changed\n") + changed_out, changed_status = run_status( + {}, "--target", target, "--host", "claude", "--source", source, + "--channel", "stable", "--release", "v1.2.3", "--json" + ) + FileUtils.cp(File.join(source, "docs/release-doc.md"), doc) + FileUtils.chmod(0o644, helper) + mode_out, mode_status = run_status( + {}, "--target", target, "--host", "claude", "--source", source, + "--channel", "stable", "--release", "v1.2.3", "--json" + ) + + failures = [] + missing_payload = JSON.parse(missing_out) + changed_payload = JSON.parse(changed_out) + mode_payload = JSON.parse(mode_out) + failures << "missing helper reported #{missing_payload.fetch('status')}" unless missing_status.exitstatus == 3 + failures << "missing helper reason was not useful" unless missing_payload["reason"].to_s.include?("release-helper") + failures << "changed document reported #{changed_payload.fetch('status')}" unless changed_status.exitstatus == 3 + failures << "changed document reason was not useful" unless changed_payload["reason"].to_s.include?("release-doc.md") + failures << "non-executable helper reported #{mode_payload.fetch('status')}" unless mode_status.exitstatus == 3 + unless mode_payload["reason"].to_s.include?("release-helper") && mode_payload["reason"].to_s.include?("executable mode") + failures << "non-executable helper reason was not useful" + end + assert_empty failures, failures.join("\n") + FileUtils.chmod(0o755, helper) + %w[changed missing].each do |mutation| + FileUtils.rm_f(root_file) + File.write(root_file, "changed notices\n") if mutation == "changed" + out, status = run_status({}, "--target", target, "--host", "claude", "--source", source, "--json") + assert_equal 3, status.exitstatus, "#{mutation}: #{out}" + assert_includes JSON.parse(out).fetch("reason"), "THIRD_PARTY-NOTICES.md" + end + end + end + end + + def test_stable_managed_surface_rejects_symlinked_parent_directory + Dir.mktmpdir("agent-workflows-status-test") do |target| + Dir.mktmpdir("external-managed-docs") do |external| + FileUtils.mkdir_p(File.join(target, "docs")) + File.write(File.join(external, "example.md"), "unchanged content") + File.symlink(external, File.join(target, "docs/solutions")) + metadata = { + "managed_bin_helper_copy_fingerprints" => {}, + "managed_pack_root_copy_fingerprints" => {}, + "managed_pack_doc_copy_fingerprints" => { + "solutions/example.md" => Digest::SHA256.file(File.join(external, "example.md")).hexdigest + } + } + error = AgentWorkflowsStatus.stable_managed_surface_error(target, metadata) + assert_includes error.to_s, "ancestor" + end + end + end + + def test_stable_managed_surface_detects_same_inode_mutation_after_hash_read + Dir.mktmpdir("agent-workflows-status-test") do |target| + FileUtils.mkdir_p(File.join(target, "bin")) + helper = File.join(target, "bin/release-helper") + File.binwrite(helper, "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, helper) + metadata = { + "managed_bin_helper_copy_fingerprints" => { + "release-helper" => Digest::SHA256.file(helper).hexdigest + }, + "managed_pack_root_copy_fingerprints" => {}, + "managed_pack_doc_copy_fingerprints" => {} + } + mutated = false + trace = TracePoint.new(:c_return) do |event| + next unless !mutated && event.method_id == :read && event.self.is_a?(File) && event.self.path == helper + + before = File.stat(helper) + File.open(helper, "r+b") { |file| file.write("X") } + File.utime(before.atime, before.mtime + 1, helper) + mutated = true + end + + error = trace.enable do + AgentWorkflowsStatus.stable_managed_surface_error(target, metadata) + end + + assert mutated, "fixture did not mutate the helper after its bytes were read" + assert_includes error.to_s, "release-helper" + assert_includes error.to_s, "changed during check" + ensure + trace&.disable + end + end + def test_companion_status_reports_delivery_and_native_state Dir.mktmpdir("agent-workflows-status-test") do |target| Dir.mktmpdir("agent-workflows-status-source") do |source| diff --git a/bin/agent-workflows-trust-audit b/bin/agent-workflows-trust-audit index 3b9a96471..15d970e65 100755 --- a/bin/agent-workflows-trust-audit +++ b/bin/agent-workflows-trust-audit @@ -247,11 +247,14 @@ module AgentWorkflowsTrustAudit values.to_a.sort end - def print_report(repo:, prs:, trust_config:, preflight_status:, preflight_output:, parsed:) + def print_report(repo:, prs:, trust_config:, preflight_status:, preflight_output:, parsed:, workflow_install:) candidate_data = candidates(parsed) puts "Repository: #{repo}" puts "Merged PR sample: #{prs.map { |pr| "##{pr.fetch('number')}" }.join(', ')}" puts "Trust config: #{trust_config || '(preflight default resolution)'}" + puts "Workflow channel: #{workflow_install.fetch(:channel)}" + puts "Workflow release ref: #{workflow_install.fetch(:release_ref)}" + puts "Workflow exact commit: #{workflow_install.fetch(:exact_commit)}" puts "Preflight status: #{preflight_status_label(preflight_status)}" puts @@ -332,7 +335,39 @@ module AgentWorkflowsTrustAudit puts end - def json_payload(repo:, prs:, trust_config:, preflight_status:, preflight_output:, parsed:) + def workflow_install_identity(metadata_path) + default_path = File.expand_path("../.agent-workflows-install.json", __dir__) + path = metadata_path || (File.file?(default_path) ? default_path : nil) + return { channel: "UNKNOWN", release_ref: "UNKNOWN", exact_commit: "UNKNOWN", tag_object: "UNKNOWN" } unless path + + metadata = JSON.parse(File.binread(path).force_encoding("UTF-8").scrub) + raise "workflow install metadata must be a JSON object: #{path}" unless metadata.is_a?(Hash) + + channel = metadata.fetch("channel", "development") + raise "invalid workflow install channel: #{channel}" unless %w[stable development].include?(channel) + + release_ref = metadata["release_ref"] + exact_commit = metadata["source_revision"] + tag_object = metadata["tag_object"] + if channel == "stable" && + (!release_ref.to_s.match?(/\Av\d+\.\d+\.\d+\z/) || + !exact_commit.to_s.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/) || + !tag_object.to_s.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/)) + raise "stable workflow install metadata lacks an exact release identity" + end + + { + channel:, + release_ref: release_ref || "none", + exact_commit: exact_commit || "UNKNOWN", + tag_object: tag_object || "none", + metadata_path: path + } + rescue JSON::ParserError => e + raise "invalid workflow install metadata #{path}: #{e.message}" + end + + def json_payload(repo:, prs:, trust_config:, preflight_status:, preflight_output:, parsed:, workflow_install:) candidate_data = candidates(parsed) candidate_trust = if preflight_error?(preflight_status) { trusted_users: [], trusted_bots: [], trusted_teams: [], unavailable: true } @@ -360,6 +395,7 @@ module AgentWorkflowsTrustAudit { repo:, + workflow_install:, prs: prs.map { |pr| pr.slice("number", "title", "url", "mergedAt") }, trust_config:, preflight_status: preflight_status_label(preflight_status), @@ -384,6 +420,7 @@ module AgentWorkflowsTrustAudit numbers: ) parsed = parse_preflight_output(preflight_output) + workflow_install = workflow_install_identity(options[:install_metadata_path]) if options[:json] puts JSON.pretty_generate( @@ -393,7 +430,8 @@ module AgentWorkflowsTrustAudit trust_config: options[:trust_config], preflight_status:, preflight_output:, - parsed: + parsed:, + workflow_install: ) ) else @@ -403,7 +441,8 @@ module AgentWorkflowsTrustAudit trust_config: options[:trust_config], preflight_status:, preflight_output:, - parsed: + parsed:, + workflow_install: ) end @@ -416,7 +455,8 @@ options = { limit: AgentWorkflowsTrustAudit::DEFAULT_LIMIT, preflight_path: AgentWorkflowsTrustAudit.default_preflight_path, repo: nil, - trust_config: nil + trust_config: nil, + install_metadata_path: nil } OptionParser.new do |opts| @@ -434,6 +474,9 @@ OptionParser.new do |opts| opts.on("--preflight PATH", "Path to pr-security-preflight, for tests or custom installs") do |value| options[:preflight_path] = value end + opts.on("--install-metadata PATH", "Installed workflow metadata to identify in the audit") do |value| + options[:install_metadata_path] = value + end opts.on("--json", "Emit JSON") { options[:json] = true } end.parse! diff --git a/bin/agent-workflows-trust-audit-test.rb b/bin/agent-workflows-trust-audit-test.rb index 9ea035937..9f833d271 100755 --- a/bin/agent-workflows-trust-audit-test.rb +++ b/bin/agent-workflows-trust-audit-test.rb @@ -79,6 +79,33 @@ def test_json_output_contains_candidates end end + def test_json_output_identifies_the_installed_release_channel_ref_and_exact_commit + with_fake_commands("ok") do |env, preflight_path, trust_config_path| + metadata_path = File.join(File.dirname(trust_config_path), "install.json") + File.write( + metadata_path, + JSON.generate( + "channel" => "stable", + "release_ref" => "v1.2.3", + "source_revision" => "a" * 40, + "tag_object" => "b" * 40 + ) + ) + + out, status = run_script( + env, preflight_path, "--json", "--install-metadata", metadata_path, + trust_config_path: + ) + payload = JSON.parse(out) + + assert status.success?, out + assert_equal "stable", payload.dig("workflow_install", "channel") + assert_equal "v1.2.3", payload.dig("workflow_install", "release_ref") + assert_equal "a" * 40, payload.dig("workflow_install", "exact_commit") + assert_equal "b" * 40, payload.dig("workflow_install", "tag_object") + end + end + def test_preflight_operational_failure_is_not_reported_as_security_block with_fake_commands("error") do |env, preflight_path, trust_config_path| out, status = run_script(env, preflight_path, trust_config_path:) diff --git a/bin/agent_doctor/workflows_component.rb b/bin/agent_doctor/workflows_component.rb index 4da5e0349..e80dfb99e 100644 --- a/bin/agent_doctor/workflows_component.rb +++ b/bin/agent_doctor/workflows_component.rb @@ -13,11 +13,11 @@ class WorkflowsComponent }.freeze STATUS_FIELDS = %w[ status host target source installed_version installed_revision available_version available_revision - checked_remote reason guidance delivery_mode native flat + channel release_ref available_release_ref exact_commit checked_remote reason guidance delivery_mode native flat ].freeze DETAIL_FIELDS = %w[ host target source installed_version installed_revision available_version available_revision - checked_remote delivery_mode native flat + channel release_ref available_release_ref exact_commit checked_remote delivery_mode native flat ].freeze def initialize(runner:) @@ -79,6 +79,8 @@ def validate_status_payload(payload, child_exit) definition = STATUS_DEFINITIONS[[payload["status"], child_exit]] common_valid = definition && payload["host"].is_a?(String) && payload["target"].is_a?(String) && payload["source"].is_a?(String) && %w[flat plugin-companion].include?(payload["delivery_mode"]) && + %w[stable development].include?(payload["channel"]) && nullable_string?(payload["release_ref"]) && + nullable_string?(payload["available_release_ref"]) && nullable_string?(payload["exact_commit"]) && [true, false].include?(payload["checked_remote"]) && nullable_string?(payload["guidance"]) && nullable_hash?(payload["native"]) && nullable_hash?(payload["flat"]) raise JSON::ParserError, "status/exit mismatch" unless common_valid && status_fields_valid?(payload) diff --git a/bin/agent_stack/installers.bash b/bin/agent_stack/installers.bash index 53c99f989..d4cfcbd54 100644 --- a/bin/agent_stack/installers.bash +++ b/bin/agent_stack/installers.bash @@ -9,9 +9,7 @@ agent_stack_command_destination_safe() { } agent_stack_install_file() { - local source_file="$1" - local destination="$2" - local temporary + local source_file="$1" destination="$2" temporary agent_stack_command_destination_safe "$destination" || return 1 if [[ "$source_file" = "$destination" && ! -L "$destination" ]]; then chmod +x "$destination" @@ -121,7 +119,25 @@ agent_stack_prepare_colocated_doctor_transition() { } agent_stack_install_commands() { - local helper source_file workflow_owns_doctor=false + local helper source_file workflow_owns_doctor=false workflow_target workflow_metadata + workflow_target="$(agent_stack_effective_workflow_target)" || return 64 + workflow_metadata="$workflow_target/.agent-workflows-install.json" + if [[ -e "$workflow_metadata" || -L "$workflow_metadata" ]]; then + if "${RUBY_BIN:-ruby}" -rjson -e ' + begin + path = ARGV.fetch(0) + exit 1 unless File.file?(path) && !File.symlink?(path) + metadata = JSON.parse(File.read(path)) + exit(metadata.is_a?(Hash) && metadata["channel"] == "stable" ? 0 : 1) + rescue JSON::ParserError, SystemCallError + # Existing ownership checks handle malformed or unreadable metadata. + exit 1 + end + ' "$workflow_metadata"; then + echo "STABLE_CHANNEL_PRESERVED: stack sync requires a development workflow target; use the release upgrader for stable installs." >&2 + return 64 + fi + fi for helper in agent-stack agent-stack-doctor; do source_file="$source_root/agent-workflows/bin/$helper" [[ -x "$source_file" ]] || { echo "Cannot install stack command: missing $source_file" >&2; return 1; } @@ -159,5 +175,5 @@ agent_stack_install_workflows() { [[ -z "$delivery_mode" ]] || args+=(--delivery-mode "$delivery_mode") [[ -z "$target" ]] || args+=(--target "$target") [[ -x "$repo/bin/install-agent-workflows" ]] || { echo "Cannot install workflows: missing $repo/bin/install-agent-workflows" >&2; return 1; } - "$repo/bin/install-agent-workflows" "${args[@]}" + AGENT_WORKFLOWS_CHANNEL=development "$repo/bin/install-agent-workflows" "${args[@]}" } diff --git a/bin/install-agent-workflows b/bin/install-agent-workflows index acaa82d9c..7dbd2def8 100755 --- a/bin/install-agent-workflows +++ b/bin/install-agent-workflows @@ -3,10 +3,13 @@ set -euo pipefail usage() { cat <<'USAGE' -Usage: install-agent-workflows [--host codex|claude|auto] [--target DIR] [--mode copy|symlink] [--delivery-mode flat|plugin-companion] +Usage: install-agent-workflows [--host codex|claude|auto] [--target DIR] [--source DIR] [--mode copy|symlink] [--delivery-mode flat|plugin-companion] (--release vX.Y.Z | --channel development) Installs this shared workflow pack into an agent home. +Stable installs use --release vX.Y.Z and copy files from that exact annotated +tag. Branch-following installs require the explicit --channel development. + Default targets: --host codex ${CODEX_HOME:-$HOME/.codex} --host claude ${CLAUDE_HOME:-$HOME/.claude} @@ -40,11 +43,28 @@ Installed paths: USAGE } +emit_install_inventory() { + ruby -rjson -e ' + separator = ARGV.index("__AGENT_WORKFLOWS_DOCS__") or abort + helpers = ARGV[0...separator] + docs = ARGV[(separator + 1)..] + puts JSON.generate({"version" => 1, "bin_helpers" => helpers, "pack_docs" => docs}) + ' -- "${bin_helpers[@]}" __AGENT_WORKFLOWS_DOCS__ "${pack_docs[@]}" +} + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_root="$repo_root" +release_verifier="$repo_root/bin/agent-workflows-release" +materialized_temp_root="" host="codex" target="" mode="copy" delivery_mode="" +channel="${AGENT_WORKFLOWS_CHANNEL:-}" +channel_was_explicit=false +release_ref="" +release_commit="" +tag_object="" metadata_name=".agent-workflows-install.json" doctor_managed_marker=".agent-workflows-managed" doctor_ownership_helper="$repo_root/bin/agent_doctor/install_ownership.rb" @@ -55,6 +75,7 @@ bin_helpers=( agent-workflows-delivery-state agent-workflows-doctor agent-workflows-refresh + agent-workflows-release agent-workflows-status agent-workflows-trust-audit install-agent-workflows @@ -71,6 +92,7 @@ pack_docs=( user-facing-coordination.md writing-style.md writing-style-asd-ste100.md + release-channel.md ) for solution_path in "$repo_root"/docs/solutions/*; do [[ -f "$solution_path" && ! -L "$solution_path" ]] || continue @@ -181,13 +203,19 @@ metadata_value() { fi ;; source_revision) - git -C "$repo_root" rev-parse HEAD 2>/dev/null || printf 'unknown\n' + if [[ "$channel" = "stable" ]]; then + printf '%s\n' "$release_commit" + else + git -C "$repo_root" rev-parse HEAD 2>/dev/null || printf 'unknown\n' + fi ;; source_branch) - git -C "$repo_root" branch --show-current 2>/dev/null || true + if [[ "$channel" = "development" ]]; then + git -C "$repo_root" branch --show-current 2>/dev/null || true + fi ;; source_remote) - git -C "$repo_root" config --get remote.origin.url 2>/dev/null || true + git -C "$source_root" config --get remote.origin.url 2>/dev/null || true ;; *) return 1 @@ -267,15 +295,19 @@ read_installed_metadata_value() { managed_pack_doc_copy_fingerprints managed_pack_helper_copy_fingerprints managed_pack_root_copy_fingerprints + managed_bin_helper_copy_fingerprints ].any? do |field| next false unless metadata.key?(field) fingerprints = metadata[field] !fingerprints.is_a?(Hash) || fingerprints.any? do |name, digest| valid_name = if field == "managed_skill_copy_fingerprints" name.is_a?(String) && !%w[. ..].include?(name) && name.match?(%r{\A[^\x00/]+\z}) + elsif field == "managed_bin_helper_copy_fingerprints" + name.is_a?(String) && name.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]*\z/) else segments = name.is_a?(String) ? name.split("/", -1) : [] - name.is_a?(String) && !name.empty? && !name.start_with?("/") && !name.include?("\0") && + name.is_a?(String) && !name.empty? && !name.start_with?("/") && + !name.match?(/[\x00-\x1f\x7f]/) && segments.none? { |segment| segment.empty? || segment == "." || segment == ".." } end !valid_name || !digest.is_a?(String) || !digest.match?(/\A[0-9a-f]{64}\z/i) @@ -1880,9 +1912,11 @@ prepare_metadata() { end end end - path, host, mode, delivery_mode, source, version, revision, branch, remote, installed_at, doc_count, *assets = ARGV - pack_docs = assets.shift(Integer(doc_count)) - bin_helpers = assets + path, host, mode, delivery_mode, source, content_root, version, revision, branch, remote, installed_at, channel, release_ref, tag_object, pack_doc_count, *inventory = ARGV + pack_doc_count = Integer(pack_doc_count, 10) + abort if pack_doc_count.negative? || pack_doc_count > inventory.length + pack_docs = inventory.shift(pack_doc_count) + bin_helpers = inventory directory_fingerprint = lambda do |root| entries = [] Find.find(root) do |entry| @@ -1902,8 +1936,8 @@ prepare_metadata() { Digest::SHA256.hexdigest(JSON.generate(entries.sort_by { |fingerprint_entry| fingerprint_entry.fetch(1) })) end skill_fingerprints = if mode == "copy" && delivery_mode == "flat" - Dir.children(File.join(source, "skills")).sort.each_with_object({}) do |name, fingerprints| - root = File.join(source, "skills", name) + Dir.children(File.join(content_root, "skills")).sort.each_with_object({}) do |name, fingerprints| + root = File.join(content_root, "skills", name) next unless File.directory?(root) && !File.symlink?(root) fingerprints[name] = directory_fingerprint.call(root) @@ -1913,7 +1947,7 @@ prepare_metadata() { end doc_fingerprints = if mode == "copy" pack_docs.to_h do |name| - doc = File.join(source, "docs", name) + doc = File.join(content_root, "docs", name) abort "missing pack document while preparing install metadata: #{doc}" unless File.file?(doc) && !File.symlink?(doc) [name, Digest::SHA256.file(doc).hexdigest] @@ -1923,7 +1957,7 @@ prepare_metadata() { end root_fingerprints = if mode == "copy" name = "THIRD_PARTY-NOTICES.md" - root_file = File.join(source, name) + root_file = File.join(content_root, name) unless File.file?(root_file) && !File.symlink?(root_file) abort "missing pack root file while preparing install metadata: #{root_file}" end @@ -1934,10 +1968,9 @@ prepare_metadata() { end helper_fingerprints = if mode == "copy" bin_helpers.to_h do |name| - helper = File.join(source, "bin", name) - unless File.file?(helper) && !File.symlink?(helper) - abort "missing pack helper while preparing install metadata: #{helper}" - end + helper = File.join(content_root, "bin", name) + abort "missing helper while preparing install metadata: #{helper}" unless File.file?(helper) && + !File.symlink?(helper) [name, Digest::SHA256.file(helper).hexdigest] end @@ -1957,8 +1990,12 @@ prepare_metadata() { "managed_skill_copy_fingerprints" => skill_fingerprints, "managed_pack_doc_copy_fingerprints" => doc_fingerprints, "managed_pack_helper_copy_fingerprints" => helper_fingerprints, - "managed_pack_root_copy_fingerprints" => root_fingerprints + "managed_pack_root_copy_fingerprints" => root_fingerprints, + "managed_bin_helper_copy_fingerprints" => helper_fingerprints } + metadata["channel"] = channel + metadata["release_ref"] = release_ref unless release_ref.empty? + metadata["tag_object"] = tag_object unless tag_object.empty? serialized_metadata = JSON.pretty_generate(metadata) + "\n" abort unless File.const_defined?(:NOFOLLOW) file = nil @@ -2005,7 +2042,7 @@ prepare_metadata() { end end end - ' "$metadata_tmp" "$host" "$mode" "$delivery_mode" "$repo_root" "$version" "$source_revision" "$source_branch" "$source_remote" "$installed_at" "${#pack_docs[@]}" "${pack_docs[@]}" "${bin_helpers[@]}" 2>/dev/null)" || prepare_status=$? + ' "$metadata_tmp" "$host" "$mode" "$delivery_mode" "$source_root" "$repo_root" "$version" "$source_revision" "$source_branch" "$source_remote" "$installed_at" "$channel" "$release_ref" "$tag_object" "${#pack_docs[@]}" "${pack_docs[@]}" "${bin_helpers[@]}" 2>/dev/null)" || prepare_status=$? if [[ "$prepare_status" -ne 0 ]]; then echo "METADATA_PREPARE_BLOCKED: could not safely create $metadata_tmp; preserved any path that exists there." >&2 echo "Verify and move that exact path aside while retaining evidence, then retry; the installer will not delete or overwrite an unverified path." >&2 @@ -3804,6 +3841,371 @@ report_delivery_state_conflict() { fi } +materialize_stable_release() { + local discovered_commit discovered_tag_object materialized_root tag_ref + + [[ "$release_ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "--release must be an exact vX.Y.Z tag, got: $release_ref" >&2 + exit 64 + } + git -C "$source_root" rev-parse --git-dir >/dev/null 2>&1 || { + echo "Stable installation requires a Git source clone: $source_root" >&2 + exit 64 + } + tag_ref="refs/tags/$release_ref" + discovered_tag_object="$(git -C "$source_root" rev-parse --verify "$tag_ref" 2>/dev/null)" || { + echo "Stable release tag is missing: $release_ref" >&2 + exit 64 + } + if [[ "$(git -C "$source_root" cat-file -t "$discovered_tag_object" 2>/dev/null || true)" != "tag" ]]; then + echo "Stable release must be an annotated tag: $release_ref" >&2 + exit 64 + fi + discovered_commit="$(git -C "$source_root" rev-parse --verify "$tag_ref^{commit}" 2>/dev/null)" || { + echo "Stable release tag does not peel to a commit: $release_ref" >&2 + exit 64 + } + + tag_object="$discovered_tag_object" + release_commit="$discovered_commit" + + if [[ ! -f "$release_verifier" || -L "$release_verifier" ]]; then + echo "Stable installation requires a trusted regular-file release verifier: $release_verifier" >&2 + exit 1 + fi + ruby "$release_verifier" verify-tag \ + --root "$source_root" --release "$release_ref" \ + --approved-commit "$release_commit" --expected-tag-object "$tag_object" >/dev/null || exit $? + + ruby "$release_verifier" verify-published \ + --root "$source_root" --release "$release_ref" \ + --approved-commit "$release_commit" --expected-tag-object "$tag_object" \ + --repository shakacode/agent-workflows >/dev/null || exit $? + + materialized_temp_root="$(mktemp -d)" + + materialized_root="$materialized_temp_root/pack" + mkdir -p "$materialized_root" + if ! git -C "$source_root" archive --output="$materialized_temp_root/pack.tar" "$release_commit" || + ! tar -xf "$materialized_temp_root/pack.tar" -C "$materialized_root"; then + rm -rf "$materialized_temp_root" + materialized_temp_root="" + echo "Unable to materialize stable release: $release_ref" >&2 + exit 1 + fi + + repo_root="$materialized_root" + load_materialized_release_inventory "$materialized_root" + doctor_ownership_helper="$repo_root/bin/agent_doctor/install_ownership.rb" + delivery_state_script="$repo_root/bin/agent-workflows-delivery-state" + trap cleanup_materialized_release EXIT +} + +load_materialized_release_inventory() { + local materialized_root="$1" installer inventory_path helpers_path docs_path + installer="$materialized_root/bin/install-agent-workflows" + inventory_path="$materialized_temp_root/install-inventory.json" + helpers_path="$materialized_temp_root/install-inventory-helpers" + docs_path="$materialized_temp_root/install-inventory-docs" + + if [[ ! -f "$installer" || -L "$installer" ]]; then + cleanup_materialized_release + echo "Stable release lacks a regular-file installer inventory provider: $release_ref" >&2 + exit 1 + fi + if ! BASH_ENV=/dev/null ENV=/dev/null "$BASH" --noprofile --norc \ + "$installer" --print-install-inventory > "$inventory_path"; then + cleanup_materialized_release + echo "Stable release installer inventory could not be read: $release_ref" >&2 + exit 1 + fi + + if ! ruby -rjson -e ' + root, inventory_path, helpers_path, docs_path = ARGV + payload = JSON.parse(File.binread(inventory_path)) + abort unless payload.is_a?(Hash) && payload.keys.sort == %w[bin_helpers pack_docs version] + abort unless payload["version"] == 1 + + validate_entry = lambda do |relative, kind| + abort unless relative.is_a?(String) && !relative.empty? + abort if relative.match?(/[\x00-\x1f\x7f]/) + parts = relative.split("/", -1) + abort if parts.any? { |part| part.empty? || part == "." || part == ".." } + if kind == :helper + abort unless parts.length == 1 && relative.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]*\z/) + parts = ["bin", relative] + else + abort if relative.start_with?("/") + parts = ["docs", *parts] + end + + current = root + parts.each_with_index do |part, index| + current = File.join(current, part) + stat = File.lstat(current) + abort if stat.symlink? + if index == parts.length - 1 + abort unless stat.file? + abort if kind == :helper && !File.executable?(current) + else + abort unless stat.directory? + end + end + rescue Errno::ENOENT, Errno::ENOTDIR + abort + end + + helpers = payload["bin_helpers"] + docs = payload["pack_docs"] + abort unless helpers.is_a?(Array) && !helpers.empty? && helpers.uniq == helpers + abort unless docs.is_a?(Array) && !docs.empty? && docs.uniq == docs + helpers.each { |entry| validate_entry.call(entry, :helper) } + docs.each { |entry| validate_entry.call(entry, :doc) } + File.binwrite(helpers_path, helpers.join("\n") + "\n") + File.binwrite(docs_path, docs.join("\n") + "\n") + ' "$materialized_root" "$inventory_path" "$helpers_path" "$docs_path"; then + cleanup_materialized_release + echo "Stable release installer inventory is invalid: $release_ref" >&2 + exit 1 + fi + + bin_helpers=() + while IFS= read -r helper; do + [[ -z "$helper" ]] || bin_helpers+=("$helper") + done < "$helpers_path" + pack_docs=() + while IFS= read -r doc_name; do + [[ -z "$doc_name" ]] || pack_docs+=("$doc_name") + done < "$docs_path" +} + +restore_obsolete_stable_copy_files() { + local index original staged expected_identity original_parent original_parent_identity attestation move_status + for ((index = ${#obsolete_managed_staging[@]} - 6; index >= 0; index -= 6)); do + original="${obsolete_managed_staging[$index]}" + staged="${obsolete_managed_staging[$((index + 1))]}" + expected_identity="${obsolete_managed_staging[$((index + 2))]}" + original_parent="${obsolete_managed_staging[$((index + 3))]}" + original_parent_identity="${obsolete_managed_staging[$((index + 4))]}" + attestation="${obsolete_managed_staging[$((index + 5))]}" + if [[ -e "$original" || -L "$original" ]] || + [[ "$(regular_file_attestation "$staged" 2>/dev/null || true)" != "$attestation" ]]; then + echo "MANAGED_SURFACE_ROLLBACK_FAILED: preserved staged managed file: $staged" >&2 + return 65 + fi + move_status=0 + move_bound_recovery_path "$staged" "$original" "$install_lock" "$install_lock_identity" \ + "$original_parent" "$original_parent_identity" "$expected_identity" || move_status=$? + if [[ "$move_status" -ne 0 ]]; then + echo "MANAGED_SURFACE_ROLLBACK_FAILED: preserved staged managed file: $staged" >&2 + return 65 + fi + done + obsolete_managed_staging=() +} + +finalize_obsolete_stable_copy_files() { + local index staged attestation cleanup_path + for ((index = 0; index < ${#obsolete_managed_staging[@]}; index += 6)); do + staged="${obsolete_managed_staging[$((index + 1))]}" + attestation="${obsolete_managed_staging[$((index + 5))]}" + if ! cleanup_path="$(remove_attested_metadata_file "$staged" "$attestation")"; then + report_metadata_cleanup_pending "${cleanup_path:-$staged}" + return 65 + fi + done + obsolete_managed_staging=() +} + +refuse_implicit_channel_migration() { + [[ "$channel" = "development" && "$channel_was_explicit" = false ]] || return 0 + [[ -e "$target/$metadata_name" || -L "$target/$metadata_name" ]] || return 0 + read_installed_metadata_value channel + if [[ "$installed_metadata_state" = corrupt ]]; then + report_corrupt_install_metadata + return 65 + fi + if [[ "$installed_metadata_state" = valid && "$installed_metadata_value" = stable ]]; then + echo "STABLE_CHANNEL_PRESERVED: use upgrade-agent-workflows --release for stable updates; an explicit reinstall is required to change channels." >&2 + return 64 + fi +} + +verify_stable_instruction_surface() { + [[ "$channel" = "stable" && "$mode" = "copy" ]] || return 0 + [[ "${install_metadata_binding_state:-}" = "present" ]] || return 0 + require_install_metadata_binding + if ! ruby -rjson -ropen3 -e ' + metadata_path, source, selected_root, target, *selected_docs = ARGV + separator = selected_docs.index("__AGENT_WORKFLOWS_HELPERS__") or abort + selected_helpers = selected_docs.drop(separator + 1) + selected_docs = selected_docs.take(separator) + metadata = JSON.parse(File.binread(metadata_path)) + abort "invalid prior installation metadata" unless metadata.is_a?(Hash) + prior_mode = metadata["mode"] + abort "invalid prior installation mode" unless %w[copy symlink].include?(prior_mode) + revision = metadata["source_revision"] + unless revision.is_a?(String) && revision.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/i) + abort "prior exact revision is unavailable" + end + roots = [source, metadata["source"]].select { |root| root.is_a?(String) && !root.empty? }.uniq + prior_source = roots.find do |root| + Open3.capture3("git", "-C", root, "cat-file", "-e", "#{revision}^{commit}").last.success? + end + abort "prior exact revision cannot be read from a source clone" unless prior_source + + if prior_mode == "symlink" + recorded_source = File.expand_path(metadata.fetch("source")) + { "docs" => selected_docs, "bin" => selected_helpers + ["agent_doctor"] }.each do |surface, selected_names| + surface_root = File.join(target, surface) + Dir.glob(File.join(surface_root, "**", "*"), File::FNM_DOTMATCH).each do |path| + next unless File.symlink?(path) + + name = path.delete_prefix("#{surface_root}/") + next if selected_names.any? { |selected| selected == name || selected.start_with?("#{name}/") } + next unless File.expand_path(File.readlink(path), File.dirname(path)) == File.join(recorded_source, surface, name) + + abort "prior managed source symlink remains installed: #{surface}/#{name}" + end + end + end + + %w[skills workflows].each do |surface| + next if surface == "workflows" && prior_mode == "symlink" + next if surface == "skills" && metadata.fetch("delivery_mode", "flat") != "flat" + + output, _error, status = Open3.capture3( + "git", "-C", prior_source, "ls-tree", "-z", "--name-only", "#{revision}:#{surface}" + ) + abort "prior #{surface} inventory is unavailable" unless status.success? + prior_names = output.split("\0") + if surface == "skills" && prior_mode == "copy" + fingerprints = metadata.fetch("managed_skill_copy_fingerprints", {}) + abort "prior skill fingerprint inventory is invalid" unless fingerprints.is_a?(Hash) + prior_names |= fingerprints.keys + elsif surface == "skills" + installed_skills = File.join(target, "skills") + recorded_source = File.expand_path(metadata.fetch("source")) + if File.directory?(installed_skills) + Dir.children(installed_skills).each do |name| + path = File.join(installed_skills, name) + next unless File.symlink?(path) + + linked_source = File.expand_path(File.readlink(path), installed_skills) + prior_names |= [name] if linked_source == File.join(recorded_source, "skills", name) + end + end + end + selected_names = Dir.children(File.join(selected_root, surface)) + prior_names.each do |name| + unless name.is_a?(String) && name.match?(/\A[^\x00-\x1f\x7f\/]+\z/) && !%w[. ..].include?(name) + abort "invalid prior #{surface} path" + end + next if name.start_with?(".") || selected_names.include?(name) + + path = File.join(target, surface, name) + next unless File.exist?(path) || File.symlink?(path) + + abort "prior release instruction root remains installed: #{surface}/#{name}" + end + end + ' "$install_metadata_snapshot_path" "$source_root" "$repo_root" "$target" "${pack_docs[@]}" \ + __AGENT_WORKFLOWS_HELPERS__ "${bin_helpers[@]}"; then + echo "STABLE_INSTRUCTION_SURFACE_CONFLICT: preserved the prior installation; use a separate clean target for this release." >&2 + return 65 + fi + require_install_metadata_binding +} + +reconcile_obsolete_stable_copy_files() { + local obsolete_records kind name expected_digest destination attestation + local original_parent original_parent_identity expected_identity staged move_status + local -a removals=() + [[ "$channel" = "stable" && "$mode" = "copy" ]] || return 0 + [[ "${install_metadata_binding_state:-}" = "present" ]] || return 0 + require_install_metadata_binding + + if ! obsolete_records="$(ruby -rjson -e ' + metadata_path, separator, *selected = ARGV + abort unless separator == "__AGENT_WORKFLOWS_DOCS__" + helper_separator = selected.index("__AGENT_WORKFLOWS_HELPERS__") or abort + selected_docs = selected.shift(helper_separator) + selected.shift + selected_helpers = selected + metadata = JSON.parse(File.binread(metadata_path)) + abort unless metadata.is_a?(Hash) + exit if metadata["mode"] == "symlink" + abort unless metadata["mode"] == "copy" + fields = { + "doc" => ["managed_pack_doc_copy_fingerprints", selected_docs], + "helper" => ["managed_bin_helper_copy_fingerprints", selected_helpers] + } + fields.each do |kind, (field, selected_names)| + fingerprints = metadata.fetch(field, {}) + abort unless fingerprints.is_a?(Hash) + fingerprints.each do |name, digest| + abort unless name.is_a?(String) && !name.include?("\t") && !name.match?(/[\x00-\x1f\x7f]/) + abort unless digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/i) + puts [kind, name, digest].join("\t") unless selected_names.include?(name) + end + end + ' "$install_metadata_snapshot_path" __AGENT_WORKFLOWS_DOCS__ "${pack_docs[@]}" \ + __AGENT_WORKFLOWS_HELPERS__ "${bin_helpers[@]}")"; then + echo "MANAGED_SURFACE_RECONCILIATION_FAILED: installed metadata inventory is invalid." >&2 + return 65 + fi + require_install_metadata_binding + + while IFS=$'\t' read -r kind name expected_digest; do + [[ -n "$kind" ]] || continue + case "$kind" in + doc) destination="$target/docs/$name" ;; + helper) destination="$target/bin/$name" ;; + *) return 65 ;; + esac + [[ -e "$destination" || -L "$destination" ]] || continue + if ! attestation="$(regular_file_attestation "$destination")" || + [[ "${attestation#*|}" != "$expected_digest" ]]; then + echo "MANAGED_SURFACE_RECONCILIATION_FAILED: preserved modified or replaced prior managed path: $destination" >&2 + return 65 + fi + removals+=("$destination" "$attestation") + done <<< "$obsolete_records" + + local index record_index + for ((index = 0; index < ${#removals[@]}; index += 2)); do + destination="${removals[$index]}" + attestation="${removals[$((index + 1))]}" + original_parent="$(dirname "$destination")" + original_parent_identity="$(recovery_directory_identity "$original_parent")" || return 65 + expected_identity="${attestation%%|*}" + record_index=$((index / 2)) + staged="$install_lock/managed-surface-$record_index" + move_status=0 + move_bound_recovery_path "$destination" "$staged" "$original_parent" "$original_parent_identity" \ + "$install_lock" "$install_lock_identity" "$expected_identity" || move_status=$? + if [[ "$move_status" -ne 0 ]]; then + restore_obsolete_stable_copy_files || true + echo "MANAGED_SURFACE_RECONCILIATION_FAILED: preserved prior managed path: $destination" >&2 + return 65 + fi + obsolete_managed_staging+=( + "$destination" "$staged" "$expected_identity" "$original_parent" "$original_parent_identity" "$attestation" + ) + if [[ "$(regular_file_attestation "$staged" 2>/dev/null || true)" != "$attestation" ]]; then + restore_obsolete_stable_copy_files || true + echo "MANAGED_SURFACE_RECONCILIATION_FAILED: staged managed path changed: $destination" >&2 + return 65 + fi + require_install_metadata_binding + done +} + +cleanup_materialized_release() { + [[ -z "$materialized_temp_root" ]] || rm -rf "$materialized_temp_root" +} + +print_install_inventory=false while [[ $# -gt 0 ]]; do case "$1" in --host) @@ -3814,6 +4216,10 @@ while [[ $# -gt 0 ]]; do target="${2:?--target requires a directory}" shift 2 ;; + --source) + source_root="${2:?--source requires a directory}" + shift 2 + ;; --mode) mode="${2:?--mode requires copy or symlink}" shift 2 @@ -3822,6 +4228,20 @@ while [[ $# -gt 0 ]]; do delivery_mode="${2:?--delivery-mode requires flat or plugin-companion}" shift 2 ;; + --channel) + channel="${2:?--channel requires stable or development}" + channel_was_explicit=true + shift 2 + ;; + --release) + release_ref="${2:?--release requires vX.Y.Z}" + [[ -n "$channel" ]] || channel="stable" + shift 2 + ;; + --print-install-inventory) + print_install_inventory=true + shift + ;; -h|--help) usage exit 0 @@ -3834,6 +4254,11 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "$print_install_inventory" = true ]]; then + emit_install_inventory + exit 0 +fi + case "$host" in codex|claude|auto) ;; *) @@ -3852,6 +4277,38 @@ esac validate_delivery_mode "--delivery-mode" +case "$channel" in + stable) + [[ -n "$release_ref" ]] || { + echo "Stable channel requires --release vX.Y.Z" >&2 + exit 64 + } + [[ "$mode" = "copy" ]] || { + echo "Stable channel requires copy mode; symlinks would follow mutable source state" >&2 + exit 64 + } + materialize_stable_release + ;; + development) + [[ -z "$release_ref" ]] || { + echo "Development channel cannot use --release" >&2 + exit 64 + } + if [[ "$(cd "$source_root" && pwd -P)" != "$(cd "$repo_root" && pwd -P)" ]]; then + echo "Development --source must match the invoked installer checkout; run the installer from that source." >&2 + exit 64 + fi + ;; + "") + echo "Choose --release vX.Y.Z for stable or --channel development for branch-following installation." >&2 + exit 64 + ;; + *) + echo "--channel must be stable or development, got: $channel" >&2 + exit 64 + ;; +esac + resolved="$(resolve_host_and_target)" host="${resolved%%:*}" target="${resolved#*:}" @@ -3863,6 +4320,7 @@ if [[ -e "$target" || -L "$target" ]]; then fi target="$canonical_target" fi +refuse_implicit_channel_migration || exit $? delivery_mode_was_explicit=false [[ -z "$delivery_mode" ]] || delivery_mode_was_explicit=true resolve_delivery_mode @@ -3921,6 +4379,7 @@ release_install_lock() { report_metadata_cleanup_pending "${cleanup_path:-$install_lock}" [[ "$status" -ne 0 ]] || status=1 fi + cleanup_materialized_release exit "$status" } trap release_install_lock EXIT @@ -4079,6 +4538,7 @@ fi binding_status=0 capture_install_metadata_binding || binding_status=$? [[ "$binding_status" -eq 0 ]] || exit "$binding_status" +refuse_implicit_channel_migration || exit $? if [[ "$delivery_mode_was_explicit" != "true" && "$delivery_mode" != "$install_metadata_bound_delivery_mode" ]]; then report_install_metadata_binding_changed @@ -4087,6 +4547,7 @@ fi prebind_install_metadata_rollback_guard || exit 1 metadata_committed=false metadata_commit_rollback_failed=false +obsolete_managed_staging=() release_install_transaction() { local status=$? cleanup_path trap - EXIT @@ -4105,6 +4566,11 @@ release_install_transaction() { -n "$migration_staging" ]]; then echo "CLEANUP_PENDING: metadata commit outcome is uncertain; preserved $migration_receipt for $migration_staging without restoring the staged flat layout." >&2 fi + if [[ "$metadata_committed" != "true" && ${#obsolete_managed_staging[@]} -gt 0 ]]; then + if ! restore_obsolete_stable_copy_files; then + status=65 + fi + fi if [[ "$metadata_committed" != "true" && "$metadata_commit_rollback_failed" != "true" && -n "${prepared_metadata_attestation:-}" ]]; then if ! cleanup_path="$(remove_attested_metadata_file "$target/$metadata_name.tmp" \ @@ -4130,6 +4596,7 @@ release_install_transaction() { report_metadata_cleanup_pending "${cleanup_path:-$install_lock}" [[ "$status" -ne 0 ]] || status=1 fi + cleanup_materialized_release exit "$status" } trap release_install_transaction EXIT @@ -4144,6 +4611,8 @@ fi verify_pack_doc_destinations verify_install_destinations verify_third_party_notices_destination +verify_stable_instruction_surface +reconcile_obsolete_stable_copy_files prepare_metadata if ! verify_install_metadata_binding; then report_install_metadata_binding_changed @@ -4262,6 +4731,7 @@ if ! verify_install_metadata_binding; then fi commit_metadata metadata_committed=true +finalize_obsolete_stable_copy_files if [[ -n "$migration_staging" ]]; then if rm -rf "$migration_staging"; then rm -f "$migration_receipt" @@ -4270,6 +4740,11 @@ if [[ -n "$migration_staging" ]]; then fi fi +guidance_shared_root="$repo_root" +if [[ "$channel" = "stable" ]]; then + guidance_shared_root="$target" +fi + printf '%s\n' \ "Installed ShakaCode agent workflows into:" \ " $target" \ @@ -4281,10 +4756,10 @@ printf '%s\n' \ " export PATH=\"$target/bin:\$PATH\"" \ "" \ "Initialize a consumer repo with:" \ - " agent-workflow-seam-doctor --init --root /path/to/consumer/repo --shared \"$repo_root\"" \ + " agent-workflow-seam-doctor --init --root /path/to/consumer/repo --shared \"$guidance_shared_root\"" \ "" \ "Validate a consumer repo with:" \ - " agent-workflow-seam-doctor --shared \"$repo_root\"" \ + " agent-workflow-seam-doctor --shared \"$guidance_shared_root\"" \ "" \ "Check for updates with:" \ " agent-workflows-status --host \"$host\"" diff --git a/bin/install-agent-workflows-test.bash b/bin/install-agent-workflows-test.bash index 2a2094223..6ccb448d0 100755 --- a/bin/install-agent-workflows-test.bash +++ b/bin/install-agent-workflows-test.bash @@ -2,6 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export AGENT_WORKFLOWS_CHANNEL=development FAKE_CODEX_DIR="$(mktemp -d)" TEST_SOURCE_ROOT="" cleanup() { @@ -115,6 +116,129 @@ new_source_repo() { git -C "$source_dir" commit --quiet -m "initial" } +write_stable_release_receipt() { + local source="$1" + local release="$2" + local receipt="$3" + local commit tag_object + commit="$(git -C "$source" rev-parse "$release^{commit}")" + tag_object="$(git -C "$source" rev-parse "refs/tags/$release")" + "$source/bin/agent-workflows-release" record-receipt \ + --root "$source" \ + --release "$release" \ + --approved-commit "$commit" \ + --expected-tag-object "$tag_object" \ + --environment stable-release \ + --change-author release-author \ + --release-actor release-actor \ + --approval-reviewer release-reviewer \ + --repository shakacode/agent-workflows \ + --workflow-run-id 12345 \ + --workflow-run-attempt 1 \ + --workflow-path .github/workflows/release.yml \ + --workflow-run-url https://github.com/shakacode/agent-workflows/actions/runs/12345 \ + --recorded-at 2026-08-25T00:00:00Z \ + --receipt "$receipt" >/dev/null +} + +write_fake_release_receipt_curl() { + local fake_bin="$1" + mkdir -p "$fake_bin" + cat > "$fake_bin/curl" <<'BASH' +#!/usr/bin/env bash +set -euo pipefail +output="" +url="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + output="${2:?--output requires a path}" + shift 2 + ;; + --header) + shift 2 + ;; + --*) + shift + ;; + *) + url="$1" + shift + ;; + esac +done +release_ref="${QA_RELEASE_REF:?}" +receipt="${QA_RELEASE_RECEIPT_FIXTURE:?}" +case "$url" in + "https://github.com/shakacode/agent-workflows/releases/download/$release_ref/agent-workflows-release-receipt.json") + install -m 0600 "$receipt" "$output" + ;; + "https://api.github.com/repos/shakacode/agent-workflows/releases/tags/$release_ref") + ruby -rjson -rdigest -e ' + receipt, output, release_ref = ARGV + data = JSON.parse(File.read(receipt)) + actor = data.dig("workflow", "actor") + repository = data.dig("workflow", "repository") + asset_url = "https://github.com/#{repository}/releases/download/#{release_ref}/agent-workflows-release-receipt.json" + payload = { + "tag_name" => release_ref, + "draft" => false, + "prerelease" => false, + "html_url" => "https://github.com/#{repository}/releases/tag/#{release_ref}", + "author" => {"login" => "github-actions[bot]", "type" => "Bot"}, + "assets" => [{ + "name" => "agent-workflows-release-receipt.json", + "state" => "uploaded", + "size" => File.size(receipt), + "digest" => "sha256:#{Digest::SHA256.file(receipt).hexdigest}", + "browser_download_url" => asset_url, + "uploader" => {"login" => "github-actions[bot]", "type" => "Bot"} + }] + } + File.write(output, JSON.generate(payload)) + ' "$receipt" "$output" "$release_ref" + ;; + "https://api.github.com/repos/shakacode/agent-workflows/actions/runs/12345/attempts/1") + ruby -rjson -e ' + receipt, output = ARGV + data = JSON.parse(File.read(receipt)) + workflow = data.fetch("workflow") + payload = { + "id" => workflow.fetch("run_id"), + "run_attempt" => workflow.fetch("run_attempt"), + "path" => workflow.fetch("path"), + "event" => "workflow_dispatch", + "status" => "completed", + "conclusion" => "success", + "html_url" => workflow.fetch("run_url"), + "head_branch" => data.fetch("release_ref"), + "head_sha" => ENV.fetch("QA_WORKFLOW_HEAD_OVERRIDE", data.fetch("peeled_commit")), + "actor" => {"login" => workflow.fetch("actor"), "type" => "User"}, + "repository" => {"full_name" => workflow.fetch("repository"), "private" => false} + } + File.write(output, JSON.generate(payload)) + ' "$receipt" "$output" + ;; + "https://api.github.com/repos/shakacode/agent-workflows/actions/runs/12345/approvals") + ruby -rjson -e ' + receipt, output = ARGV + reviewer = JSON.parse(File.read(receipt)).dig("approval", "reviewer") + File.write(output, JSON.generate([{ + "state" => "approved", + "environments" => [{"name" => "stable-release"}], + "user" => {"login" => reviewer, "type" => "User"} + }])) + ' "$receipt" "$output" + ;; + *) + echo "unexpected curl URL: $url" >&2 + exit 1 + ;; +esac +BASH + chmod +x "$fake_bin/curl" +} + new_source_repo_with_legacy_model_routing_history() { local source_dir="$1" @@ -5559,7 +5683,7 @@ RUBY set +e output="$(QA_METADATA_READ_COUNTER="$counter" QA_INSTALL_METADATA="$target/.agent-workflows-install.json" \ - RUBYOPT="-r$injection" "$ROOT/bin/install-agent-workflows" --host codex --target "$target" 2>&1)" + RUBYOPT="-r$injection" "$ROOT/bin/install-agent-workflows" --channel development --host codex --target "$target" 2>&1)" status=$? set -e @@ -5918,6 +6042,8 @@ require "json" module OverwriteMetadataAfterSecondRead def parse(source, *args) value = super + # Count delivery-mode reads; channel checks must not shift recovery injection. + return value unless ARGV.length == 2 && ARGV.fetch(1) == "delivery_mode" counter = ENV.fetch("QA_METADATA_READ_COUNTER") count = File.exist?(counter) ? File.read(counter).to_i + 1 : 1 File.write(counter, count) @@ -5964,6 +6090,8 @@ require "json" module DeleteMetadataAfterSecondRead def parse(source, *args) value = super + # Count delivery-mode reads; channel checks must not shift recovery injection. + return value unless ARGV.length == 2 && ARGV.fetch(1) == "delivery_mode" counter = ENV.fetch("QA_METADATA_READ_COUNTER") count = File.exist?(counter) ? File.read(counter).to_i + 1 : 1 File.write(counter, count) @@ -8527,6 +8655,311 @@ RUBY assert_contains "$retry_output" "Installed ShakaCode agent workflows" } +test_stable_install_materializes_the_exact_annotated_release() { + local tmp source target release_commit tag_object installed_content tagged_content host receipt fake_bin + tmp="$(mktemp -d)" + source="$tmp/source" + target="$tmp/codex-home" + mkdir -p "$source" + new_source_repo "$source" + release_commit="$(git -C "$source" rev-parse HEAD)" + git -C "$source" tag -a v0.1.0 -m "Agent Workflows v0.1.0" + tag_object="$(git -C "$source" rev-parse refs/tags/v0.1.0)" + receipt="$tmp/release-receipt.json" + fake_bin="$tmp/fake-bin" + write_stable_release_receipt "$source" v0.1.0 "$receipt" + write_fake_release_receipt_curl "$fake_bin" + printf '\nMutable development-only change.\n' >> "$source/skills/status/SKILL.md" + git -C "$source" add skills/status/SKILL.md + git -C "$source" commit --quiet -m "development change after release" + + git -C "$source" show v0.1.0:skills/status/SKILL.md > "$tmp/tagged-status-skill" + tagged_content="$(shasum "$tmp/tagged-status-skill" | awk '{print $1}')" + for host in codex claude; do + target="$tmp/$host-home" + env -u AGENT_WORKFLOWS_CHANNEL PATH="$fake_bin:$PATH" \ + QA_RELEASE_REF=v0.1.0 QA_RELEASE_RECEIPT_FIXTURE="$receipt" \ + "$source/bin/install-agent-workflows" \ + --host "$host" --target "$target" --release v0.1.0 >"$tmp/$host-install.out" + + installed_content="$(shasum "$target/skills/status/SKILL.md" | awk '{print $1}')" + [[ "$installed_content" = "$tagged_content" ]] || fail "$host stable install consumed mutable branch content" + ruby -rjson -e ' + metadata = JSON.parse(File.read(ARGV.fetch(0))) + expected = {"host" => ARGV.fetch(1), "channel" => "stable", "release_ref" => ARGV.fetch(2), + "source_revision" => ARGV.fetch(3), "tag_object" => ARGV.fetch(4)} + expected.each { |key, value| abort metadata.inspect unless metadata[key] == value } + ' "$target/.agent-workflows-install.json" "$host" v0.1.0 "$release_commit" "$tag_object" + grep -Fq -- "--shared \"$target\"" "$tmp/$host-install.out" || \ + fail "$host stable guidance did not use installed release content" + ! grep -Eq -- '--shared ".*/tmp\.[^/]*/pack"' "$tmp/$host-install.out" || \ + fail "$host stable guidance named a deleted materialized release root" + done +} + +test_stable_install_ignores_hostile_materialization_state_environment() { + local tmp source target release_commit tag_object receipt fake_bin + tmp="$(mktemp -d)" + source="$tmp/source" + target="$tmp/codex-home" + mkdir -p "$source" + new_source_repo "$source" + release_commit="$(git -C "$source" rev-parse HEAD)" + git -C "$source" tag -a v0.1.0 -m "Agent Workflows v0.1.0" + tag_object="$(git -C "$source" rev-parse refs/tags/v0.1.0)" + receipt="$tmp/release-receipt.json" + fake_bin="$tmp/fake-bin" + write_stable_release_receipt "$source" v0.1.0 "$receipt" + write_fake_release_receipt_curl "$fake_bin" + printf '\nHOSTILE_MUTABLE_SENTINEL\n' >> "$source/skills/status/SKILL.md" + git -C "$source" add skills/status/SKILL.md + git -C "$source" commit --quiet -m "mutable sentinel after release" + + env -u AGENT_WORKFLOWS_CHANNEL \ + PATH="$fake_bin:$PATH" \ + QA_RELEASE_REF=v0.1.0 \ + QA_RELEASE_RECEIPT_FIXTURE="$receipt" \ + AGENT_WORKFLOWS_MATERIALIZED_RELEASE=v0.1.0 \ + AGENT_WORKFLOWS_RELEASE_SOURCE_ROOT="$source" \ + AGENT_WORKFLOWS_RELEASE_REF=v0.1.0 \ + AGENT_WORKFLOWS_RELEASE_COMMIT="$release_commit" \ + AGENT_WORKFLOWS_TAG_OBJECT="$tag_object" \ + "$source/bin/install-agent-workflows" \ + --target "$target" --release v0.1.0 >"$tmp/install.out" + + ! grep -Fq HOSTILE_MUTABLE_SENTINEL "$target/skills/status/SKILL.md" || \ + fail "hostile caller materialization state installed mutable source content" +} + +test_stable_install_never_executes_the_candidate_release_verifier() { + local tmp source target candidate_commit receipt fake_bin sentinel trusted_commit + tmp="$(mktemp -d)" + source="$tmp/source" + target="$tmp/codex-home" + receipt="$tmp/release-receipt.json" + fake_bin="$tmp/fake-bin" + sentinel="$tmp/candidate-verifier-executed" + mkdir -p "$source" + new_source_repo "$source" + trusted_commit="$(git -C "$source" rev-parse HEAD)" + + cat > "$source/bin/agent-workflows-release" <<'RUBY' +#!/usr/bin/env ruby +File.write(ENV.fetch("QA_CANDIDATE_VERIFIER_SENTINEL"), "executed\n") +exit 0 +RUBY + chmod +x "$source/bin/agent-workflows-release" + git -C "$source" add bin/agent-workflows-release + git -C "$source" commit --quiet -m "hostile candidate verifier" + candidate_commit="$(git -C "$source" rev-parse HEAD)" + git -C "$source" tag -a v0.1.0 -m "Agent Workflows v0.1.0" + git -C "$source" checkout --quiet --detach "$trusted_commit" + write_stable_release_receipt "$source" v0.1.0 "$receipt" + write_fake_release_receipt_curl "$fake_bin" + + env -u AGENT_WORKFLOWS_CHANNEL \ + PATH="$fake_bin:$PATH" \ + QA_CANDIDATE_VERIFIER_SENTINEL="$sentinel" \ + QA_RELEASE_REF=v0.1.0 \ + QA_RELEASE_RECEIPT_FIXTURE="$receipt" \ + "$source/bin/install-agent-workflows" \ + --target "$target" --release v0.1.0 >"$tmp/install.out" + + [[ ! -e "$sentinel" ]] || fail "stable install executed verifier bytes from candidate $candidate_commit" +} + +test_stable_install_rejects_forged_github_workflow_provenance() { + local tmp source target receipt fake_bin output exit_code + tmp="$(mktemp -d)" + source="$tmp/source" + target="$tmp/codex-home" + receipt="$tmp/release-receipt.json" + fake_bin="$tmp/fake-bin" + mkdir -p "$source" + new_source_repo "$source" + git -C "$source" tag -a v0.1.0 -m "Agent Workflows v0.1.0" + write_stable_release_receipt "$source" v0.1.0 "$receipt" + write_fake_release_receipt_curl "$fake_bin" + + set +e + output="$(env -u AGENT_WORKFLOWS_CHANNEL \ + PATH="$fake_bin:$PATH" \ + QA_RELEASE_REF=v0.1.0 \ + QA_RELEASE_RECEIPT_FIXTURE="$receipt" \ + QA_WORKFLOW_HEAD_OVERRIDE="0000000000000000000000000000000000000000" \ + "$source/bin/install-agent-workflows" \ + --target "$target" --release v0.1.0 2>&1)" + exit_code=$? + set -e + + [[ "$exit_code" -ne 0 ]] || fail "stable install accepted forged GitHub workflow provenance" + assert_contains "$output" "workflow run metadata" + [[ ! -e "$target/.agent-workflows-install.json" ]] || \ + fail "forged workflow provenance wrote stable install metadata" +} + +test_stable_install_rejects_local_annotated_tag_without_protected_release_receipt() { + local tmp source target output status fake_bin + tmp="$(mktemp -d)" + source="$tmp/source" + target="$tmp/codex-home" + fake_bin="$tmp/fake-bin" + mkdir -p "$source" + new_source_repo "$source" + git -C "$source" tag -a v0.1.0 -m "locally fabricated release" + write_fake_release_receipt_curl "$fake_bin" + + set +e + output="$(env -u AGENT_WORKFLOWS_CHANNEL PATH="$fake_bin:$PATH" \ + "$source/bin/install-agent-workflows" \ + --target "$target" --release v0.1.0 2>&1)" + status=$? + set -e + + [[ "$status" -ne 0 ]] || fail "stable install accepted a local tag with no protected release receipt" + assert_contains "$output" "protected release receipt" + [[ ! -e "$target/.agent-workflows-install.json" ]] || \ + fail "receipt-less local tag wrote install metadata" +} + +test_stable_install_accepts_linked_worktree_and_rejects_non_git_source() { + local tmp source worktree target receipt fake_bin output status unpacked + tmp="$(mktemp -d)" + source="$tmp/source" + worktree="$tmp/worktree" + target="$tmp/codex-home" + receipt="$tmp/release-receipt.json" + fake_bin="$tmp/fake-bin" + mkdir -p "$source" + new_source_repo "$source" + git -C "$source" tag -a v0.1.0 -m "Agent Workflows v0.1.0" + write_stable_release_receipt "$source" v0.1.0 "$receipt" + write_fake_release_receipt_curl "$fake_bin" + git -C "$source" worktree add --quiet --detach "$worktree" v0.1.0 + [[ -f "$worktree/.git" ]] || fail "expected linked worktree .git file" + + env -u AGENT_WORKFLOWS_CHANNEL PATH="$fake_bin:$PATH" \ + QA_RELEASE_REF=v0.1.0 QA_RELEASE_RECEIPT_FIXTURE="$receipt" \ + "$worktree/bin/install-agent-workflows" \ + --target "$target" --release v0.1.0 >"$tmp/worktree-install.out" + assert_file "$target/.agent-workflows-install.json" + + unpacked="$tmp/unpacked" + mkdir -p "$unpacked" + rsync -a --exclude .git "$worktree/" "$unpacked/" + set +e + output="$(env -u AGENT_WORKFLOWS_CHANNEL "$unpacked/bin/install-agent-workflows" \ + --target "$tmp/non-git-target" --release v0.1.0 2>&1)" + status=$? + set -e + [[ "$status" -ne 0 ]] || fail "stable install accepted a non-Git source" + assert_contains "$output" "requires a Git source clone" +} + +test_stable_copy_guidance_uses_authenticated_bootstrap() { + # The release-helper tests execute the canonical bootstrap, including its negative controls. + ruby -e ' + ARGV.each do |path| + text = File.read(path) + unless text.match?(/\]\((?:docs\/)?release-channel\.md#install-update-and-roll-back\)/) + abort "#{path}: missing canonical authenticated bootstrap link" + end + abort "#{path}: duplicate cold bootstrap bypasses the canonical flow" if text.include?("git clone --no-checkout") + end + ' "$ROOT/README.md" "$ROOT/docs/adoption.md" "$ROOT/docs/installation-and-upgrades.md" || \ + fail "stable copy guidance does not use the authenticated bootstrap" + # Ruby checks the literal bound installer path used in the companion guidance. + # shellcheck disable=SC2016 + ruby -e ' + ARGV.each do |path| + text = File.read(path) + heading = if File.basename(path) == "getting-started.md" + "Two things to know about Route B:" + else + "## Native Plugin And Host Installer Boundaries" + end + section = text.split(heading, 2).fetch(1).split("\n## ", 2).first + bootstrap = section.index("(release-channel.md#install-update-and-roll-back)") + installer = section.index(%q["$source/bin/install-agent-workflows"]) + unless bootstrap && installer && bootstrap < installer && section.include?("--delivery-mode plugin-companion") + abort "#{path}: companion install must first bind its executable through the exact-release bootstrap" + end + unless section.include?("--host claude") && !section.include?("--host codex") && + section.gsub(/\s+/, " ").include?("does not make the native Codex plugin stable") + abort "#{path}: stable companion guidance must distinguish native Codex skills from copied assets" + end + end + ' "$ROOT/docs/getting-started.md" "$ROOT/docs/installation-and-upgrades.md" || \ + fail "native companion guidance lacks a bootstrap-bound installer" + # Ruby checks literal documentation text, without shell expansion. + # shellcheck disable=SC2016 + ruby -e ' + ARGV.each do |path| + text = File.read(path) + if text.match?(/^codex plugin marketplace add .*--ref/) + abort "#{path}: marketplace ref alone must not be offered as a stable Codex plugin install" + end + text.scan(/```bash\n(.*?)```/m).flatten.each do |example| + if example.include?("--host codex") && example.include?("--release") && + example.include?("--delivery-mode plugin-companion") + abort "#{path}: stable Codex example must install verified flat skills, not only companion assets" + end + end + unless text.include?("development/unverified") && + text.match?(/\((?:docs\/)?release-channel\.md#install-update-and-roll-back\)|\(#install-update-and-roll-back\)/) + abort "#{path}: native Codex guidance must label the URL route and direct stable users to the copy bootstrap" + end + next if File.basename(path) == "adoption.md" + + unless text.include?("/plugin marketplace add shakacode/agent-workflows@vX.Y.Z") + abort "#{path}: retain the pinned Claude marketplace route" + end + end + ' "$ROOT/README.md" "$ROOT/docs/adoption.md" "$ROOT/docs/getting-started.md" "$ROOT/docs/installation-and-upgrades.md" "$ROOT/docs/release-channel.md" || \ + fail "native Codex guidance confuses marketplace metadata with pinned plugin code" +} + +test_stable_install_rejects_malformed_missing_lightweight_and_version_mismatched_refs() { + local tmp source target output status + tmp="$(mktemp -d)" + source="$tmp/source" + mkdir -p "$source" + new_source_repo "$source" + + for release in release-0.1.0 v9.9.8; do + target="$tmp/$release-target" + set +e + output="$(env -u AGENT_WORKFLOWS_CHANNEL "$source/bin/install-agent-workflows" \ + --target "$target" --release "$release" 2>&1)" + status=$? + set -e + [[ "$status" -ne 0 ]] || fail "stable install accepted invalid release $release" + [[ ! -e "$target/.agent-workflows-install.json" ]] || fail "invalid release $release wrote metadata" + done + + git -C "$source" tag v0.1.0 + target="$tmp/lightweight-target" + set +e + output="$(env -u AGENT_WORKFLOWS_CHANNEL "$source/bin/install-agent-workflows" \ + --target "$target" --release v0.1.0 2>&1)" + status=$? + set -e + [[ "$status" -ne 0 ]] || fail "stable install accepted a lightweight tag" + assert_contains "$output" "annotated tag" + [[ ! -e "$target/.agent-workflows-install.json" ]] || fail "lightweight tag wrote metadata" + + git -C "$source" tag -a v9.9.9 -m "mismatched version" + target="$tmp/mismatch-target" + set +e + output="$(env -u AGENT_WORKFLOWS_CHANNEL "$source/bin/install-agent-workflows" \ + --target "$target" --release v9.9.9 2>&1)" + status=$? + set -e + [[ "$status" -ne 0 ]] || fail "stable install accepted source/ref version mismatch" + assert_contains "$output" "does not match VERSION" + [[ ! -e "$target/.agent-workflows-install.json" ]] || fail "version mismatch wrote metadata" +} + main() { TEST_SOURCE_ROOT="$(mktemp -d)" new_source_repo "$TEST_SOURCE_ROOT" @@ -8752,6 +9185,14 @@ main() { test_upgrade_rolls_back_when_consumer_seam_fails test_failed_upgrade_restores_companion_delivery_mode_and_layout test_upgrade_validates_consumer_root_after_install + test_stable_install_materializes_the_exact_annotated_release + test_stable_install_ignores_hostile_materialization_state_environment + test_stable_install_never_executes_the_candidate_release_verifier + test_stable_install_rejects_forged_github_workflow_provenance + test_stable_install_rejects_local_annotated_tag_without_protected_release_receipt + test_stable_install_accepts_linked_worktree_and_rejects_non_git_source + test_stable_copy_guidance_uses_authenticated_bootstrap + test_stable_install_rejects_malformed_missing_lightweight_and_version_mismatched_refs ) local test_name diff --git a/bin/repository-security-policy-test.rb b/bin/repository-security-policy-test.rb index d57c7342e..60352320d 100755 --- a/bin/repository-security-policy-test.rb +++ b/bin/repository-security-policy-test.rb @@ -230,9 +230,49 @@ def test_supply_chain_policy_places_human_review_at_release_promotion policy = File.read(File.join(ROOT, "docs/repository-supply-chain.md")) assert_includes policy, "Stable release promotion, not ordinary pull-request development" - assert_includes policy, "agent-workflows/issues/296" assert_includes policy, "Automated reviews remain advisory" - assert_includes policy, "There is no supported human-reviewed install or upgrade path today" + assert_includes policy, "protected, immutable, annotated `vX.Y.Z` tags" + assert_includes policy, "protected `stable-release` environment" + assert_includes policy, "Cryptographic tag signatures are not required or checked" + assert_includes policy, "dispatch the workflow from the exact candidate commit" + assert_includes policy, "--channel development" + end + + def test_release_workflow_binds_a_protected_environment_review_to_the_exact_tagged_commit + path = File.join(ROOT, ".github/workflows/release.yml") + workflow = File.read(path) + + jobs = load_yaml_file(path).fetch("jobs") + verification = jobs.fetch("verify") + release = jobs.fetch("release") + assert_equal({ "contents" => "read" }, verification.fetch("permissions")) + refute verification.key?("environment") + assert_equal "verify", release.fetch("needs") + assert_equal "stable-release", release.fetch("environment") + full_step = verification.fetch("steps").find { |step| step["run"] == "bin/validate" } + refute_nil full_step + assert_equal "true", full_step.dig("env", "CI") + refute(verification.fetch("steps").any? { |step| step["continue-on-error"] }) + release_steps = release.fetch("steps") + approval_index = release_steps.index { |step| step["id"] == "approval" } + tag_index = release_steps.index { |step| step["id"] == "tag" } + assert_operator approval_index, :<, tag_index + assert_includes release_steps.fetch(approval_index).fetch("run"), "${approval_reviewer,,}" + assert_includes release_steps.fetch(tag_index).fetch("run"), '"repos/$REPOSITORY/git/refs"' + assert_includes workflow, "environment: stable-release" + assert_includes workflow, "actions/runs/$RUN_ID/approvals" + assert_includes workflow, "WORKFLOW_SHA: ${{ github.workflow_sha }}" + assert_includes workflow, 'test "$WORKFLOW_SHA" = "$CANDIDATE_SHA"' + assert_includes workflow, "bin/agent-workflows-release verify-tag" + assert_includes workflow, "bin/agent-workflows-release record-receipt" + assert_includes workflow, "--expected-tag-object" + assert_includes workflow, "--approved-commit" + assert_includes workflow, "--repository \"$REPOSITORY\"" + assert_includes workflow, "--workflow-run-id \"$RUN_ID\"" + assert_includes workflow, "--workflow-run-attempt \"$RUN_ATTEMPT\"" + assert_includes workflow, "--workflow-path \"$WORKFLOW_PATH\"" + assert_includes workflow, "gh release create" + refute_match(/verify-(?:commit|tag).*signature|git verify-tag|git verify-commit/, workflow) end def test_dependabot_proposes_pinned_action_updates_for_review diff --git a/bin/upgrade-agent-workflows b/bin/upgrade-agent-workflows index 020eb49a3..797c1907d 100755 --- a/bin/upgrade-agent-workflows +++ b/bin/upgrade-agent-workflows @@ -3,12 +3,15 @@ set -euo pipefail usage() { cat <<'USAGE' -Usage: upgrade-agent-workflows [--host codex|claude|auto] [--target DIR] [--source DIR] [--mode copy|symlink] [--delivery-mode flat|plugin-companion] [--consumer-root DIR] [--dry-run] [--no-fetch] +Usage: upgrade-agent-workflows [--host codex|claude|auto] [--target DIR] [--source DIR] [--mode copy|symlink] [--delivery-mode flat|plugin-companion] (--release vX.Y.Z | --channel development) [--consumer-root DIR] [--dry-run] [--no-fetch] Updates the source clone, reinstalls this shared workflow pack into an agent home, and optionally validates one or more consumer repository seams. -Network note: by default this command runs git fetch/pull in the source clone. +Stable update and rollback each require an exact --release vX.Y.Z. Development +branch following requires the explicit --channel development. + +Network note: by default this command fetches the selected channel's refs. Use --no-fetch for an already-updated local clone. With --host auto and an explicit unmarked target, Codex is the deterministic backward-compatible default; pass --host claude for arbitrary Claude targets. @@ -21,6 +24,8 @@ target="" source="" mode="" delivery_mode="" +channel="${AGENT_WORKFLOWS_CHANNEL:-}" +release_ref="" dry_run=false fetch=true consumer_roots=() @@ -175,6 +180,15 @@ while [[ $# -gt 0 ]]; do delivery_mode="${2:?--delivery-mode requires flat or plugin-companion}" shift 2 ;; + --channel) + channel="${2:?--channel requires stable or development}" + shift 2 + ;; + --release) + release_ref="${2:?--release requires vX.Y.Z}" + [[ -n "$channel" ]] || channel="stable" + shift 2 + ;; --consumer-root) consumer_roots+=("${2:?--consumer-root requires a directory}") shift 2 @@ -227,6 +241,35 @@ resolved="$(resolve_host_and_target)" host="${resolved%%:*}" target="${resolved#*:}" metadata_path="$target/$metadata_name" +installed_channel="$(metadata_value "$metadata_path" channel 2>/dev/null || printf 'development\n')" + +case "$channel" in + stable) + [[ "$release_ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Stable update or rollback requires --release vX.Y.Z" >&2 + exit 64 + } + ;; + development) + [[ -z "$release_ref" ]] || { + echo "Development channel cannot use --release" >&2 + exit 64 + } + ;; + "") + echo "Choose --release vX.Y.Z for stable or --channel development for branch-following updates." >&2 + exit 64 + ;; + *) + echo "--channel must be stable or development, got: $channel" >&2 + exit 64 + ;; +esac + +if [[ -f "$metadata_path" && "$channel" != "$installed_channel" ]]; then + echo "CHECK_FAILED refusing channel change from $installed_channel to $channel; reinstall explicitly instead" >&2 + exit 3 +fi if [[ -z "$source" ]]; then source="$(metadata_value "$metadata_path" source 2>/dev/null || printf '%s\n' "$script_root")" @@ -244,8 +287,13 @@ if [[ -z "$delivery_mode" ]]; then delivery_mode="$(metadata_value "$metadata_path" delivery_mode 2>/dev/null || printf 'flat\n')" fi -status_script="$source/bin/agent-workflows-status" -install_script="$source/bin/install-agent-workflows" +if [[ "$channel" = "stable" ]]; then + status_script="$target/bin/agent-workflows-status" + install_script="$target/bin/install-agent-workflows" +else + status_script="$source/bin/agent-workflows-status" + install_script="$source/bin/install-agent-workflows" +fi if [[ ! -x "$status_script" || ! -x "$install_script" ]]; then echo "CHECK_FAILED missing agent-workflows scripts in source: $source" >&2 @@ -253,7 +301,8 @@ if [[ ! -x "$status_script" || ! -x "$install_script" ]]; then fi if [[ "$dry_run" = "true" ]]; then - status_args=(--host "$host" --target "$target" --source "$source" --delivery-mode "$delivery_mode") + status_args=(--host "$host" --target "$target" --source "$source" --delivery-mode "$delivery_mode" --channel "$channel") + [[ -z "$release_ref" ]] || status_args+=(--release "$release_ref") if [[ "$fetch" = "true" ]]; then status_args+=(--fetch) fi @@ -270,18 +319,33 @@ fi old_revision="$(metadata_value "$metadata_path" source_revision 2>/dev/null || printf 'unknown\n')" if [[ "$fetch" = "true" ]]; then - update_source_clone + if [[ "$channel" = "stable" ]]; then + git -C "$source" fetch --quiet --prune --tags origin + else + update_source_clone + fi fi backup_target completed=false trap 'status=$?; if [[ "$completed" != "true" ]]; then restore_target; fi; exit "$status"' ERR -"$install_script" --host "$host" --target "$target" --mode "$mode" --delivery-mode "$delivery_mode" +install_args=(--host "$host" --target "$target" --mode "$mode" --delivery-mode "$delivery_mode") +if [[ "$channel" = "stable" ]]; then + install_args+=(--source "$source" --release "$release_ref") + "$install_script" "${install_args[@]}" +else + install_args+=(--channel development) + "$install_script" "${install_args[@]}" +fi if [[ "${#consumer_roots[@]}" -gt 0 ]]; then + validation_shared_root="$source" + if [[ "$channel" = "stable" ]]; then + validation_shared_root="$target" + fi for consumer_root in "${consumer_roots[@]}"; do - "$target/bin/agent-workflow-seam-doctor" --root "$consumer_root" --shared "$source" + "$target/bin/agent-workflow-seam-doctor" --root "$consumer_root" --shared "$validation_shared_root" done fi @@ -289,4 +353,13 @@ completed=true trap - ERR new_revision="$(metadata_value "$metadata_path" source_revision 2>/dev/null || printf 'unknown\n')" -echo "UPGRADE_COMPLETE $(short_revision "$old_revision") $(short_revision "$new_revision") target=$target source=$source" +old_version="$(metadata_value "$backup_target_dir/$metadata_name" version 2>/dev/null || printf 'unknown\n')" +new_version="$(metadata_value "$metadata_path" version 2>/dev/null || printf 'unknown\n')" +operation="UPGRADE_COMPLETE" +if [[ "$channel" = "stable" ]] && ruby -e ' + require "rubygems" + exit(Gem::Version.new(ARGV.fetch(1)) < Gem::Version.new(ARGV.fetch(0)) ? 0 : 1) +' "$old_version" "$new_version"; then + operation="ROLLBACK_COMPLETE" +fi +echo "$operation $(short_revision "$old_revision") $(short_revision "$new_revision") channel=$channel release_ref=${release_ref:-none} target=$target source=$source" diff --git a/bin/upgrade-agent-workflows-test.rb b/bin/upgrade-agent-workflows-test.rb new file mode 100755 index 000000000..ddb116ed5 --- /dev/null +++ b/bin/upgrade-agent-workflows-test.rb @@ -0,0 +1,821 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "fileutils" +require "digest" +require "json" +require "minitest/autorun" +require "open3" +require "tmpdir" + +ROOT = File.expand_path("..", __dir__) + +class UpgradeAgentWorkflowsTest < Minitest::Test + def test_stack_install_refuses_to_change_a_resolved_stable_target_to_development + with_release_repository do |source, target, _commits| + install_stable(source, target, "v0.1.0") + metadata_path = File.join(target, ".agent-workflows-install.json") + prior_metadata = File.binread(metadata_path) + stack_root = File.join(File.dirname(source), "stack") + FileUtils.mkdir_p(stack_root) + File.symlink(source, File.join(stack_root, "agent-workflows")) + %w[explicit default auto].each do |selection| + output, status = run_command( + "env", "CODEX_HOME=#{target}", + "bash", "-c", + 'source "$1"; source_root="$2"; mode=copy; delivery_mode=flat; ' \ + 'host=codex; target="$3"; [[ "$4" != auto ]] || host=auto; [[ "$4" != default ]] || target=""; ' \ + "agent_stack_install_workflows", + "stack-test", File.join(source, "bin/agent_stack/installers.bash"), stack_root, target, selection + ) + assert_equal 64, status.exitstatus, output + assert_includes output, "STABLE_CHANNEL_PRESERVED" + assert_equal prior_metadata, File.binread(metadata_path) + assert_equal "release one\n", File.read(File.join(target, "docs/release-channel.md")) + end + end + end + + def test_full_stack_sync_preserves_colocated_stable_doctor_before_any_install + with_release_repository do |source, target, _commits| + install_stable(source, target, "v0.1.0") + metadata = File.join(target, ".agent-workflows-install.json") + marker = File.join(target, "bin/agent_doctor/.agent-workflows-managed") + prior_metadata = File.binread(metadata) + prior_marker = File.binread(marker) + added = "bin/agent_doctor/development-only.rb" + File.write(File.join(source, added), "# development-only doctor code\n") + git(source, "branch", "-M", "main") + git(source, "add", added) + git(source, "commit", "--quiet", "-m", "development doctor fixture") + git(source, "remote", "add", "origin", source) + base = File.dirname(source) + stack = File.join(base, "stack") + FileUtils.mkdir_p(stack) + File.symlink(source, File.join(stack, "agent-workflows")) + %w[agent-coordination agent-coordination-dashboard].each do |name| + repo = File.join(stack, name) + FileUtils.mkdir_p(File.join(repo, "bin")) + File.write(File.join(repo, "README.md"), "# Local fixture\n") + if name == "agent-coordination" + bootstrap = File.join(repo, "bin/agent-coord") + File.write(bootstrap, "#!/usr/bin/env bash\nprintf unexpected > \"$3/coord-bootstrap-ran\"\n") + File.chmod(0o755, bootstrap) + end + git(repo, "init", "--quiet", "--initial-branch=main") + git(repo, "config", "user.email", "test@example.invalid") + git(repo, "config", "user.name", "Stack Test") + git(repo, "add", ".") + git(repo, "commit", "--quiet", "-m", "local fixture") + git(repo, "remote", "add", "origin", repo) + end + output, status = run_command( + "env", "AGENT_STACK_AGENT_WORKFLOWS_URL=#{source}", + "AGENT_STACK_AGENT_COORDINATION_URL=#{stack}/agent-coordination", + "AGENT_STACK_AGENT_COORDINATION_DASHBOARD_URL=#{stack}/agent-coordination-dashboard", + File.join(source, "bin/agent-stack"), "sync", "--no-fetch", "--source-root", stack, + "--compat-root", File.join(base, "compat"), "--runtime-root", File.join(base, "runtime"), + "--host", "codex", "--target", target, "--mode", "copy", + "--agent-coord-install-dir", File.join(target, "bin") + ) + assert_equal 64, status.exitstatus, output + assert_includes output, "STABLE_CHANNEL_PRESERVED" + assert_equal prior_metadata, File.binread(metadata) + assert_equal prior_marker, File.binread(marker) + refute File.exist?(File.join(target, added)) + refute File.exist?(File.join(target, "bin/coord-bootstrap-ran")) + refute File.exist?(File.join(target, "bin/agent-stack")) + end + end + + def test_development_source_override_cannot_mislabel_the_installed_checkout + with_release_repository do |source, target, _commits| + other_source = File.join(File.dirname(source), "other-source") + FileUtils.mkdir_p(other_source) + File.write(File.join(other_source, "VERSION"), "9.9.9\n") + output, status = run_command( + File.join(source, "bin/install-agent-workflows"), "--target", target, + "--channel", "development", "--source", other_source + ) + assert_equal 64, status.exitstatus, output + assert_includes output, "Development --source must match" + refute File.exist?(target), "rejected source override mutated the target" + end + end + + def test_symlink_conversion_refuses_omitted_managed_docs_and_helpers + with_release_repository(add_release_two_assets: true) do |source, target, commits| + output, status = run_command( + File.join(source, "bin/install-agent-workflows"), "--target", target, + "--channel", "development", "--mode", "symlink" + ) + assert status.success?, output + metadata_path = File.join(target, ".agent-workflows-install.json") + prior_metadata = File.binread(metadata_path) + unrelated = File.join(target, "docs/user-notes") + File.symlink(File.join(File.dirname(target), "user-notes"), unrelated) + %w[docs/release-two-only.md bin/agent-workflows-release-two-only].each do |relative| + output, status = run_command( + File.join(source, "bin/install-agent-workflows"), "--target", target, + "--source", source, "--release", "v0.1.0" + ) + assert_equal 65, status.exitstatus, output + assert_includes output, "STABLE_INSTRUCTION_SURFACE_CONFLICT" + assert_includes output, relative + assert_equal prior_metadata, File.binread(metadata_path) + assert File.symlink?(File.join(target, "docs/release-channel.md")) + assert File.symlink?(File.join(target, relative)) + File.unlink(File.join(target, relative)) + end + install_stable(source, target, "v0.1.0") + assert File.symlink?(unrelated) + assert_install_metadata(target, release_ref: "v0.1.0", revision: commits.fetch("v0.1.0")) + end + end + + def test_stable_install_verifies_recorded_attempt_after_a_later_failed_rerun + with_release_repository do |source, target, commits| + install_stable(source, target, "v0.1.0") + + assert_install_metadata(target, release_ref: "v0.1.0", revision: commits.fetch("v0.1.0")) + end + end + + def test_explicit_stable_reinstall_converts_development_symlinks_to_copies + with_release_repository do |source, target, commits| + output, status = run_command( + File.join(source, "bin/install-agent-workflows"), "--target", target, + "--channel", "development", "--mode", "symlink" + ) + assert status.success?, output + assert File.symlink?(File.join(target, "docs/release-channel.md")) + user_skill = File.join(target, "skills/user-owned") + File.symlink(File.join(File.dirname(target), "user-skill"), user_skill) + + install_stable(source, target, "v0.1.0") + + assert File.symlink?(user_skill), "unrelated user skill symlink was not preserved" + refute File.symlink?(File.join(target, "docs/release-channel.md")) + assert_equal "release one\n", File.read(File.join(target, "docs/release-channel.md")) + assert_equal "release two\n", File.read(File.join(source, "docs/release-channel.md")) + assert_install_metadata(target, release_ref: "v0.1.0", revision: commits.fetch("v0.1.0")) + end + end + + def test_symlink_conversion_refuses_committed_and_uncommitted_omitted_managed_skills + with_release_repository(release_two_instruction_surface: "skills") do |source, target, _commits| + uncommitted = File.join(source, "skills/uncommitted-only") + FileUtils.mkdir_p(uncommitted) + File.write(File.join(uncommitted, "SKILL.md"), "uncommitted instructions\n") + output, status = run_command( + File.join(source, "bin/install-agent-workflows"), "--target", target, + "--channel", "development", "--mode", "symlink" + ) + assert status.success?, output + metadata_path = File.join(target, ".agent-workflows-install.json") + prior_metadata = File.binread(metadata_path) + + %w[release-two-only uncommitted-only].each do |name| + output, status = run_command( + File.join(source, "bin/install-agent-workflows"), "--target", target, + "--source", source, "--release", "v0.1.0" + ) + + assert_equal 65, status.exitstatus, output + assert_includes output, "STABLE_INSTRUCTION_SURFACE_CONFLICT" + assert_includes output, "skills/#{name}" + assert_equal prior_metadata, File.binread(metadata_path) + assert File.symlink?(File.join(target, "docs/release-channel.md")) + assert_equal "release two\n", File.read(File.join(target, "docs/release-channel.md")) + installed_skill = File.join(target, "skills", name) + assert File.symlink?(installed_skill) + File.unlink(installed_skill) + end + end + end + + def test_rejected_obsolete_release_files_preserve_all_prior_content + with_release_repository(add_release_two_assets: true) do |source, target, _commits| + install_stable(source, target, "v0.1.1") + metadata_path = File.join(target, ".agent-workflows-install.json") + prior_metadata = File.binread(metadata_path) + %w[docs/release-two-only.md bin/agent-workflows-release-two-only].each do |relative| + obsolete = File.join(target, relative) + original = File.binread(obsolete) + File.write(obsolete, "user modification\n") + + output, status = run_command( + File.join(source, "bin/install-agent-workflows"), "--target", target, + "--source", source, "--release", "v0.1.0" + ) + + assert_equal 65, status.exitstatus, output + assert_includes output, "MANAGED_SURFACE_RECONCILIATION_FAILED" + assert_equal "release two\n", File.read(File.join(target, "docs/release-channel.md")) + assert_equal prior_metadata, File.binread(metadata_path) + assert_equal "user modification\n", File.read(obsolete) + File.binwrite(obsolete, original) + end + end + end + + def test_stable_reinstall_refuses_removed_instruction_roots_before_replacement + %w[skills workflows].each do |surface| + with_release_repository(release_two_instruction_surface: surface) do |source, target, _commits| + install_stable(source, target, "v0.1.1") + metadata_path = File.join(target, ".agent-workflows-install.json") + prior_metadata = File.binread(metadata_path) + newer_path = File.join(target, surface, "release-two-only") + + output, status = run_command( + File.join(source, "bin/install-agent-workflows"), "--target", target, + "--source", source, "--release", "v0.1.0" + ) + + assert_equal 65, status.exitstatus, output + assert_includes output, "STABLE_INSTRUCTION_SURFACE_CONFLICT" + assert_includes output, "#{surface}/release-two-only" + assert_includes output, "separate clean target" + assert_equal "release two\n", File.read(File.join(target, "docs/release-channel.md")) + assert_equal prior_metadata, File.binread(metadata_path) + assert_path_exists newer_path + end + end + end + + def test_stable_root_file_fingerprint_uses_selected_release_content + with_release_repository do |source, target, _commits| + File.write(File.join(source, "THIRD_PARTY-NOTICES.md"), "mutable development notices\n") + + install_stable(source, target, "v0.1.0") + + installed_notices = File.join(target, "THIRD_PARTY-NOTICES.md") + metadata = JSON.parse(File.read(File.join(target, ".agent-workflows-install.json"))) + assert_equal git(source, "show", "v0.1.0:THIRD_PARTY-NOTICES.md"), File.read(installed_notices).strip + assert_equal Digest::SHA256.file(installed_notices).hexdigest, + metadata.fetch("managed_pack_root_copy_fingerprints").fetch("THIRD_PARTY-NOTICES.md") + end + end + + def test_stable_upgrade_and_rollback_each_select_an_explicit_immutable_release + with_release_repository do |source, target, commits| + %w[codex claude].each do |host| + host_target = "#{target}-#{host}" + install_stable(source, host_target, "v0.1.0", host:) + + output, status = run_command( + File.join(host_target, "bin/upgrade-agent-workflows"), + "--host", host, "--target", host_target, "--source", source, + "--release", "v0.1.1", "--no-fetch" + ) + assert status.success?, output + assert_install_metadata(host_target, release_ref: "v0.1.1", revision: commits.fetch("v0.1.1")) + assert_equal "release two\n", File.read(File.join(host_target, "docs", "release-channel.md")) + + output, status = run_command( + File.join(host_target, "bin/upgrade-agent-workflows"), + "--host", host, "--target", host_target, "--source", source, + "--release", "v0.1.0", "--no-fetch" + ) + assert status.success?, output + assert_includes output, "ROLLBACK_COMPLETE" + assert_install_metadata(host_target, release_ref: "v0.1.0", revision: commits.fetch("v0.1.0")) + assert_equal "release one\n", File.read(File.join(host_target, "docs", "release-channel.md")) + end + end + end + + def test_stable_upgrade_and_rollback_use_the_selected_release_asset_inventory + with_release_repository(add_release_two_assets: true) do |source, target, _commits| + install_stable_with_release_installer(source, target, "v0.1.0") + + upgrade_output, upgrade_status = run_command( + File.join(target, "bin/upgrade-agent-workflows"), + "--target", target, "--source", source, + "--release", "v0.1.1", "--no-fetch" + ) + release_two_doc = File.join(target, "docs/release-two-only.md") + release_two_helper = File.join(target, "bin/agent-workflows-release-two-only") + upgrade_installed_release_two_doc = File.file?(release_two_doc) + upgrade_installed_release_two_helper = File.file?(release_two_helper) + unrelated = File.join(target, "docs/user-owned.md") + File.write(unrelated, "user owned\n") + rollback_output, rollback_status = run_command( + File.join(target, "bin/upgrade-agent-workflows"), + "--target", target, "--source", source, + "--release", "v0.1.0", "--no-fetch" + ) + + failures = [] + failures << "upgrade failed: #{upgrade_output}" unless upgrade_status.success? + failures << "upgrade omitted the selected release's new pack document" unless upgrade_installed_release_two_doc + failures << "upgrade omitted the selected release's new helper" unless upgrade_installed_release_two_helper + failures << "rollback failed: #{rollback_output}" unless rollback_status.success? + failures << "rollback did not report completion" unless rollback_output.include?("ROLLBACK_COMPLETE") + failures << "rollback retained the newer release's managed pack document" if File.exist?(release_two_doc) + failures << "rollback retained the newer release's managed helper" if File.exist?(release_two_helper) + failures << "rollback removed an unrelated user-owned path" unless File.read(unrelated) == "user owned\n" + + assert_empty failures, failures.join("\n") + end + end + + def test_upgrade_refuses_to_cross_between_stable_and_development_channels + with_release_repository do |source, target, _commits| + install_stable(source, target, "v0.1.0") + + output, status = run_command( + File.join(target, "bin/upgrade-agent-workflows"), + "--target", target, "--source", source, "--channel", "development", "--no-fetch" + ) + + assert_equal 3, status.exitstatus, output + assert_includes output, "refusing channel change from stable to development" + + development_target = "#{target}-development" + output, status = Open3.capture2e( + File.join(source, "bin/install-agent-workflows"), + "--target", development_target, "--channel", "development" + ) + assert status.success?, output + output, status = run_command( + File.join(development_target, "bin/upgrade-agent-workflows"), + "--target", development_target, "--source", source, "--release", "v0.1.1", "--no-fetch" + ) + + assert_equal 3, status.exitstatus, output + assert_includes output, "refusing channel change from development to stable" + end + end + + def test_stable_upgrade_validates_consumers_with_installed_release_content + with_release_repository do |source, target, _commits| + install_stable(source, target, "v0.1.0") + sentinel = File.join(File.dirname(target), "mutable-scanner-executed") + scanner = File.join(source, "skills/secure-github-actions/lib/secure_github_actions_scanner.rb") + File.write( + scanner, + "File.write(#{sentinel.inspect}, \"executed\\n\")\nraise \"mutable source scanner executed\"\n" + ) + + output, status = run_command( + File.join(target, "bin/upgrade-agent-workflows"), + "--target", target, + "--source", source, + "--release", "v0.1.1", + "--consumer-root", ROOT, + "--no-fetch" + ) + + assert status.success?, output + refute_path_exists sentinel, "stable seam validation executed the mutable source scanner" + end + end + + def test_stable_dry_run_checks_installed_content_before_comparing_another_release + %w[skills workflows].each do |surface| + with_release_repository(release_two_instruction_surface: surface) do |source, target, _commits| + install_stable(source, target, "v0.1.0") + metadata = File.binread(File.join(target, ".agent-workflows-install.json")) + output, status = run_command( + File.join(target, "bin/upgrade-agent-workflows"), "--target", target, + "--source", source, "--release", "v0.1.1", "--no-fetch", "--dry-run" + ) + assert status.success?, output + assert_includes output, "UPGRADE_AVAILABLE" + assert_equal metadata, File.binread(File.join(target, ".agent-workflows-install.json")) + end + end + end + + def test_stable_dry_run_requires_published_release_provenance + with_release_repository do |source, target, _commits| + install_stable(source, target, "v0.1.0") + receipt_path = File.join(@release_receipt_dir, "v0.1.1.json") + receipt = File.binread(receipt_path) + File.unlink(receipt_path) + output, status = run_command( + File.join(target, "bin/upgrade-agent-workflows"), "--target", target, + "--source", source, "--release", "v0.1.1", "--no-fetch", "--dry-run" + ) + assert_equal 3, status.exitstatus, output + assert_includes output, "CHECK_FAILED" + data = JSON.parse(receipt) + data.fetch("approval")["human_non_author"] = false + File.write(receipt_path, JSON.generate(data)) + output, status = run_command( + File.join(target, "bin/upgrade-agent-workflows"), "--target", target, + "--source", source, "--release", "v0.1.1", "--no-fetch", "--dry-run" + ) + assert_equal 3, status.exitstatus, output + assert_includes output, "protected-environment approval" + end + end + + def test_stable_status_rejects_reduced_metadata_and_symlinked_ancestors + with_release_repository do |source, target, _commits| + install_stable(source, target, "v0.1.0") + metadata_path = File.join(target, ".agent-workflows-install.json") + original = File.binread(metadata_path) + %w[managed_bin_helper_copy_fingerprints managed_pack_doc_copy_fingerprints managed_pack_root_copy_fingerprints].each do |field| + reduced = JSON.parse(original) + reduced[field] = {} + File.write(metadata_path, JSON.generate(reduced)) + output, status = run_command(File.join(target, "bin/agent-workflows-status"), "--target", target, "--source", source, "--json") + assert_equal 3, status.exitstatus, "#{field}: #{output}" + assert_includes output, "inventory does not match release: #{field}" + end + File.write(metadata_path, original) + docs = File.join(target, "docs/solutions") + saved = File.join(File.dirname(target), "external-solutions") + FileUtils.mv(docs, saved) + File.symlink(saved, docs) + output, status = run_command(File.join(target, "bin/agent-workflows-status"), "--target", target, "--source", source, "--json") + assert_equal 3, status.exitstatus, output + assert_includes output, "ancestor" + end + end + + def test_stable_status_checks_installed_auxiliary_runtimes + with_release_repository do |source, target, _commits| + install_stable(source, target, "v0.1.0", host: "claude") + companion = "#{target}-companion" + install_stable(source, companion, "v0.1.0", host: "claude") + plugin = File.join(companion, "plugins/cache/agent-workflows/scw/0.1.0") + FileUtils.mkdir_p([File.join(plugin, "skills/example"), File.join(plugin, ".claude-plugin")]) + File.write(File.join(plugin, "skills/example/SKILL.md"), "example\n") + File.write(File.join(plugin, ".claude-plugin/plugin.json"), JSON.generate(name: "scw", version: "0.1.0", skills: "./skills/")) + File.write(File.join(companion, "settings.json"), JSON.generate(enabledPlugins: { "scw@agent-workflows" => true })) + File.write(File.join(companion, "plugins/installed_plugins.json"), JSON.generate(version: 2, plugins: { + "scw@agent-workflows" => [{ scope: "user", installPath: plugin, version: "0.1.0" }] + })) + preview = ["--host", "claude", "--target", companion, "--source", source, "--delivery-mode", "plugin-companion"] + output, status = run_command(File.join(companion, "bin/agent-workflows-status"), *preview, "--json") + assert_equal 0, status.exitstatus, output + output, status = run_command(File.join(companion, "bin/upgrade-agent-workflows"), *preview, "--dry-run", "--release", "v0.1.0", "--no-fetch") + assert status.success?, output + assert_equal "flat", JSON.parse(File.read(File.join(companion, ".agent-workflows-install.json"))).fetch("delivery_mode") + output, status = run_command(File.join(source, "bin/install-agent-workflows"), "--host", "claude", "--target", companion, + "--release", "v0.1.0", "--delivery-mode", "plugin-companion") + assert status.success?, output + + [[target, "bin/agent_doctor/contract.rb"], [companion, "lib/agent-workflows/secure_github_actions_scanner.rb"], + [target, "LICENSE"], [companion, "LICENSE"]].each do |home, relative| + command = [File.join(home, "bin/agent-workflows-status"), "--host", "claude", "--target", home, "--source", source, "--json"] + output, status = run_command(*command) + assert_equal 0, status.exitstatus, output + path = File.join(home, relative) + original = File.binread(path) + %w[missing modified].each do |mutation| + FileUtils.rm_f(path) + File.write(path, "raise 'modified runtime'\n") if mutation == "modified" + output, status = run_command(*command) + assert_equal 3, status.exitstatus, "#{relative} #{mutation}: #{output}" + assert_equal "CHECK_FAILED", JSON.parse(output).fetch("status") + File.binwrite(path, original) + end + end + + command = [File.join(companion, "bin/agent-workflows-status"), "--host", "claude", "--target", companion, + "--source", source, "--delivery-mode", "flat", "--json"] + output, status = run_command(*command) + refute status.success?, output + File.write(File.join(companion, "settings.json"), JSON.generate(enabledPlugins: { "scw@agent-workflows" => false })) + output, status = run_command(*command) + assert_equal 0, status.exitstatus, output + scanner = File.join(companion, "lib/agent-workflows/secure_github_actions_scanner.rb") + original = File.binread(scanner) + %w[missing modified].each do |mutation| + FileUtils.rm_f(scanner) + File.write(scanner, "raise 'modified runtime'\n") if mutation == "modified" + output, status = run_command(*command) + assert_equal 3, status.exitstatus, output + assert_includes JSON.parse(output).fetch("reason"), "scanner" + File.binwrite(scanner, original) + end + File.write(File.join(companion, "settings.json"), JSON.generate(enabledPlugins: { "scw@agent-workflows" => true })) + + lib = File.join(companion, "lib") + external = "#{companion}-lib" + FileUtils.mv(lib, external) + File.symlink(external, lib) + output, status = run_command(File.join(companion, "bin/agent-workflows-status"), "--host", "claude", "--target", companion, "--source", source, "--json") + assert_equal 3, status.exitstatus, output + assert_includes output, "ancestor" + end + end + + def test_stable_status_binds_version_to_installed_release_when_checking_upgrades + with_release_repository do |source, target, _commits| + install_stable(source, target, "v0.1.0", host: "claude") + metadata_path = File.join(target, ".agent-workflows-install.json") + original = File.binread(metadata_path) + command = [File.join(target, "bin/agent-workflows-status"), "--host", "claude", "--target", target, "--source", source, "--json"] + [nil, false, "9.9.9"].each do |version| + metadata = JSON.parse(original) + version.nil? ? metadata.delete("version") : metadata["version"] = version + File.write(metadata_path, JSON.generate(metadata)) + [[], ["--release", "v0.1.1"]].each do |selection| + output, status = run_command(*command, *selection) + assert_equal 3, status.exitstatus, output + assert_includes JSON.parse(output).fetch("reason"), "version" + end + end + File.binwrite(metadata_path, original) + output, status = run_command(*command, "--release", "v0.1.1") + assert_equal 1, status.exitstatus, output + payload = JSON.parse(output) + assert_equal "0.1.0", payload.fetch("installed_version") + assert_equal "0.1.1", payload.fetch("available_version") + end + end + + def test_stable_status_requires_every_installed_flat_skill + with_release_repository do |source, target, _commits| + install_stable(source, target, "v0.1.0", host: "claude") + command = [File.join(target, "bin/agent-workflows-status"), "--host", "claude", "--target", target, "--source", source, "--json"] + output, status = run_command(*command) + assert_equal 0, status.exitstatus, output + + skills = File.join(target, "skills") + skill = File.join(skills, "status") + saved_skill = "#{target}-saved-skill" + FileUtils.mv(skill, saved_skill) + output, status = run_command(*command) + assert_equal 3, status.exitstatus, output + assert_includes JSON.parse(output).fetch("reason"), "skill" + metadata_path = File.join(target, ".agent-workflows-install.json") + original_metadata = File.binread(metadata_path) + metadata = JSON.parse(original_metadata) + metadata.delete("delivery_mode") + File.write(metadata_path, JSON.generate(metadata)) + output, status = run_command(*command) + assert_equal 3, status.exitstatus, output + assert_includes JSON.parse(output).fetch("reason"), "skill" + File.binwrite(metadata_path, original_metadata) + FileUtils.mv(saved_skill, skill) + + saved_skills = "#{target}-saved-skills" + FileUtils.mv(skills, saved_skills) + FileUtils.mkdir_p(skills) + output, status = run_command(*command) + assert_equal 3, status.exitstatus, output + assert_includes JSON.parse(output).fetch("reason"), "skill" + FileUtils.rmdir(skills) + output, status = run_command(*command) + assert_equal 3, status.exitstatus, output + assert_includes JSON.parse(output).fetch("reason"), "skill" + FileUtils.mv(saved_skills, skills) + + output, status = run_command(*command) + assert_equal 0, status.exitstatus, output + end + end + + private + + def with_release_repository(add_release_two_assets: false, release_two_instruction_surface: nil) + Dir.mktmpdir("upgrade-agent-workflows-test") do |tmp| + source = File.join(tmp, "source") + target = File.join(tmp, "codex-home") + FileUtils.mkdir_p(source) + system("rsync", "-a", "--exclude", ".git", "#{ROOT}/", "#{source}/", exception: true) + git(source, "init", "--quiet") + # Background maintenance must not outlive this disposable repository. + git(source, "config", "maintenance.auto", "false") + git(source, "config", "user.email", "upgrade-test@example.com") + git(source, "config", "user.name", "Upgrade Test") + write_version(source, "0.1.0") + File.write(File.join(source, "docs/release-channel.md"), "release one\n") + git(source, "add", ".") + git(source, "commit", "--quiet", "-m", "release one") + commit_one = git(source, "rev-parse", "HEAD") + git(source, "tag", "-a", "v0.1.0", "-m", "Agent Workflows v0.1.0") + + write_version(source, "0.1.1") + File.write(File.join(source, "docs/release-channel.md"), "release two\n") + add_release_two_only_assets(source) if add_release_two_assets + if release_two_instruction_surface + extra = File.join(source, release_two_instruction_surface, "release-two-only") + if release_two_instruction_surface == "skills" + FileUtils.mkdir_p(extra) + extra = File.join(extra, "SKILL.md") + end + File.write(extra, "newer release instructions\n") + end + git(source, "add", ".") + git(source, "commit", "--quiet", "-m", "release two") + commit_two = git(source, "rev-parse", "HEAD") + git(source, "tag", "-a", "v0.1.1", "-m", "Agent Workflows v0.1.1") + + @release_receipt_dir = File.join(tmp, "release-receipts") + @fake_bin = File.join(tmp, "fake-bin") + FileUtils.mkdir_p([@release_receipt_dir, @fake_bin]) + write_release_receipt(source, "v0.1.0") + write_release_receipt(source, "v0.1.1") + write_fake_curl + + yield source, target, { "v0.1.0" => commit_one, "v0.1.1" => commit_two } + ensure + @release_receipt_dir = nil + @fake_bin = nil + end + end + + def add_release_two_only_assets(source) + File.write(File.join(source, "docs/release-two-only.md"), "release two only\n") + helper = File.join(source, "bin/agent-workflows-release-two-only") + File.write(helper, "#!/usr/bin/env bash\nprintf 'release two helper\\n'\n") + FileUtils.chmod(0o755, helper) + + installer = File.join(source, "bin/install-agent-workflows") + content = File.read(installer) + unless content.sub!( + "bin_helpers=(\n", + "bin_helpers=(\n agent-workflows-release-two-only\n" + ) + raise "missing helper inventory insertion point" + end + unless content.sub!( + "pack_docs=(\n", + "pack_docs=(\n release-two-only.md\n" + ) + raise "missing pack document inventory insertion point" + end + + File.write(installer, content) + end + + def write_release_receipt(source, release) + commit = git(source, "rev-parse", "#{release}^{commit}") + tag_object = git(source, "rev-parse", "refs/tags/#{release}") + receipt = File.join(@release_receipt_dir, "#{release}.json") + output, status = Open3.capture2e( + File.join(source, "bin/agent-workflows-release"), "record-receipt", + "--root", source, + "--release", release, + "--approved-commit", commit, + "--expected-tag-object", tag_object, + "--environment", "stable-release", + "--change-author", "release-author", + "--release-actor", "release-actor", + "--approval-reviewer", "release-reviewer", + "--repository", "shakacode/agent-workflows", + "--workflow-run-id", "12345", + "--workflow-run-attempt", "1", + "--workflow-path", ".github/workflows/release.yml", + "--workflow-run-url", "https://github.com/shakacode/agent-workflows/actions/runs/12345", + "--recorded-at", "2026-08-25T00:00:00Z", + "--receipt", receipt + ) + raise output unless status.success? + end + + def write_fake_curl + path = File.join(@fake_bin, "curl") + File.write(path, <<~'BASH') + #!/usr/bin/env bash + set -euo pipefail + output="" + url="" + while [[ $# -gt 0 ]]; do + case "$1" in + --output) output="${2:?}"; shift 2 ;; + --header) shift 2 ;; + --*) shift ;; + *) url="$1"; shift ;; + esac + done + release="${QA_RELEASE_REF:?}" + receipt="${QA_RELEASE_RECEIPT_DIR:?}/$release.json" + case "$url" in + "https://github.com/shakacode/agent-workflows/releases/download/$release/agent-workflows-release-receipt.json") + install -m 0600 "$receipt" "$output" + ;; + "https://api.github.com/repos/shakacode/agent-workflows/releases/tags/$release") + ruby -rjson -rdigest -e ' + receipt, output, release_ref = ARGV + data = JSON.parse(File.read(receipt)) + workflow = data.fetch("workflow") + repository = workflow.fetch("repository") + payload = { + "tag_name" => release_ref, + "draft" => false, + "prerelease" => false, + "html_url" => "https://github.com/#{repository}/releases/tag/#{release_ref}", + "author" => {"login" => "github-actions[bot]", "type" => "Bot"}, + "assets" => [{ + "name" => "agent-workflows-release-receipt.json", + "state" => "uploaded", + "size" => File.size(receipt), + "digest" => "sha256:#{Digest::SHA256.file(receipt).hexdigest}", + "browser_download_url" => "https://github.com/#{repository}/releases/download/#{release_ref}/agent-workflows-release-receipt.json", + "uploader" => {"login" => "github-actions[bot]", "type" => "Bot"} + }] + } + File.write(output, JSON.generate(payload)) + ' "$receipt" "$output" "$release" + ;; + "https://api.github.com/repos/shakacode/agent-workflows/actions/runs/12345"|\ + "https://api.github.com/repos/shakacode/agent-workflows/actions/runs/12345/attempts/1") + ruby -rjson -e ' + receipt, output, url = ARGV + data = JSON.parse(File.read(receipt)) + workflow = data.fetch("workflow") + payload = { + "id" => workflow.fetch("run_id"), + "run_attempt" => workflow.fetch("run_attempt"), + "path" => workflow.fetch("path"), + "event" => "workflow_dispatch", + "status" => "completed", + "conclusion" => "success", + "html_url" => workflow.fetch("run_url"), + "head_branch" => data.fetch("release_ref"), + "head_sha" => data.fetch("peeled_commit"), + "actor" => {"login" => workflow.fetch("actor"), "type" => "User"}, + "repository" => {"full_name" => workflow.fetch("repository"), "private" => false} + } + unless url.end_with?("/attempts/1") + payload["run_attempt"] = 2 + payload["conclusion"] = "failure" + end + File.write(output, JSON.generate(payload)) + ' "$receipt" "$output" "$url" + ;; + "https://api.github.com/repos/shakacode/agent-workflows/actions/runs/12345/approvals") + ruby -rjson -e ' + receipt, output = ARGV + reviewer = JSON.parse(File.read(receipt)).dig("approval", "reviewer") + File.write(output, JSON.generate([{ + "state" => "approved", + "environments" => [{"name" => "stable-release"}], + "user" => {"login" => reviewer, "type" => "User"} + }])) + ' "$receipt" "$output" + ;; + *) + echo "unexpected curl URL: $url" >&2 + exit 1 + ;; + esac + BASH + FileUtils.chmod(0o755, path) + end + + def write_version(source, version) + File.write(File.join(source, "VERSION"), "#{version}\n") + %w[.claude-plugin/plugin.json .codex-plugin/plugin.json].each do |relative| + path = File.join(source, relative) + manifest = JSON.parse(File.read(path)) + manifest["version"] = version + File.write(path, "#{JSON.pretty_generate(manifest)}\n") + end + end + + def install_stable(source, target, release, host: "codex") + output, status = run_command( + File.join(source, "bin/install-agent-workflows"), + "--host", host, "--target", target, "--release", release + ) + assert status.success?, output + end + + def install_stable_with_release_installer(source, target, release, host: "codex") + Dir.mktmpdir("materialized-release-installer") do |materialized| + archive = File.join(materialized, "release.tar") + system("git", "-C", source, "archive", "--output", archive, release, exception: true) + system("tar", "-xf", archive, "-C", materialized, exception: true) + FileUtils.rm_f(archive) + output, status = run_command( + File.join(materialized, "bin/install-agent-workflows"), + "--host", host, "--target", target, "--source", source, "--release", release + ) + assert status.success?, output + end + end + + def assert_install_metadata(target, release_ref:, revision:) + metadata = JSON.parse(File.read(File.join(target, ".agent-workflows-install.json"))) + assert_equal "stable", metadata.fetch("channel") + assert_equal release_ref, metadata.fetch("release_ref") + assert_equal revision, metadata.fetch("source_revision") + end + + def run_command(*command) + env = { "AGENT_WORKFLOWS_CHANNEL" => nil } + if @fake_bin + env["PATH"] = "#{@fake_bin}:#{ENV.fetch('PATH')}" + env["QA_RELEASE_RECEIPT_DIR"] = @release_receipt_dir + release_index = command.index("--release") + env["QA_RELEASE_REF"] = command.fetch(release_index + 1) if release_index + end + Open3.capture2e(env, *command) + end + + def git(root, *args) + output, status = Open3.capture2e("git", "-C", root, *args) + raise output unless status.success? + + output.strip + end +end diff --git a/bin/validate b/bin/validate index e9ca5c3da..28398f4ef 100755 --- a/bin/validate +++ b/bin/validate @@ -64,8 +64,10 @@ ruby bin/delivery-policy-replay-test.rb echo "== status unit tests ==" ruby bin/agent-workflows-refresh-test.rb +ruby bin/agent-workflows-release-test.rb ruby bin/agent-workflows-status-test.rb ruby bin/agent-workflows-trust-audit-test.rb +ruby bin/upgrade-agent-workflows-test.rb echo "== repository security policy ==" ruby bin/repository-security-policy-test.rb diff --git a/docs/README.md b/docs/README.md index b18ee0a76..5f6d93b5c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,6 +61,7 @@ and implementation plans are collected in the final reference section. | Report a suspected vulnerability in this pack | [Security Policy](../SECURITY.md) | | Configure claims, heartbeats, cancellation, and fail-closed coordination state | [Coordination Backend](coordination-backend.md) | | Apply consumer-repository release branch policy | [Release Branching](release-branching.md) | +| Install or promote an immutable human-reviewed source-pack release | [Stable Release Channel](release-channel.md) | | Pause or resume work around an agent runner restart | [Agent Runner Restarts](agent-runner-restarts.md) | | Unwind a bad agent merge: revert scope, order, bookkeeping, and authority | [Revert Runbook](revert-runbook.md) | diff --git a/docs/adoption.md b/docs/adoption.md index 481348fde..563400f8f 100644 --- a/docs/adoption.md +++ b/docs/adoption.md @@ -173,16 +173,30 @@ libraries provides no guarantee of token or cost savings, quality, or security. files, protected-branch requirements, review bots, and which checks are cheap locally versus reserved for hosted CI. -2. **Install or enable the shared skills for the user/agent.** Clone - [`shakacode/agent-workflows`](https://github.com/shakacode/agent-workflows) - and use `bin/install-agent-workflows --host codex` or - `bin/install-agent-workflows --host claude`, or use the agent platform's - normal user-skill installation mechanism. +2. **Install or enable the shared skills for the user/agent.** For a stable + first install, follow the + [authenticated bootstrap](release-channel.md#install-update-and-roll-back) + into a durable agent home. It authenticates verifier bytes against an + independently trusted pin and verifies release provenance before executing + any code from the selected tag. Set the installer's `--target` to + `${CODEX_HOME:-$HOME/.codex}` or another intended agent home. + + Use `--host claude --target "${CLAUDE_HOME:-$HOME/.claude}"` for + Claude Code. The installer binds the exact tag and candidate bytes to the + public GitHub release asset, successful exact-head release workflow run, and + protected-environment approval before copying candidate content. The + platform's normal user-skill installation mechanism is also available when + it can pin the same exact release. For stable Codex skills, use this verified + [copy bootstrap](release-channel.md#install-update-and-roll-back): the current + native Codex URL route is **development/unverified**. Its marketplace `--ref` + does not pin the separately fetched plugin code, and stable companion assets + do not make the native plugin stable. Claude's pinned relative-source route + remains available; see [Native Plugin Paths](installation-and-upgrades.md#native-plugin-paths). 3. **Initialize the consumer seam.** From the consumer repo, run: ```bash - agent-workflow-seam-doctor --init --shared "$HOME/src/agent-workflows" + agent-workflow-seam-doctor --init --shared "$target" ``` The initializer preserves valid repo-owned wrappers and existing policy, @@ -200,7 +214,7 @@ libraries provides no guarantee of token or cost savings, quality, or security. agent-workflow-seam-doctor --init \ --validate-command 'bin/validate' \ --test-command 'bin/test' \ - --shared "$HOME/src/agent-workflows" + --shared "$target" ``` `--validate-command` and `--test-command` accept non-empty single-line shell @@ -376,12 +390,13 @@ source clone: agent-workflows-status --host codex ``` -Use `upgrade-agent-workflows` to update the source clone, reinstall, and run the +Use `upgrade-agent-workflows` to install a selected exact release and run the seam doctor against one or more consumer repos: ```bash upgrade-agent-workflows \ --host codex \ + --release vX.Y.Z \ --consumer-root /path/to/consumer/repo ``` diff --git a/docs/compound-engineering.md b/docs/compound-engineering.md index 59a1df665..e5e2275ce 100644 --- a/docs/compound-engineering.md +++ b/docs/compound-engineering.md @@ -149,7 +149,7 @@ directory and load the checked-out CE plugin directly: ```bash export CLAUDE_CONFIG_DIR="$HOME/.claude/profiles/ce-pilot" "$AGENT_WORKFLOWS_SOURCE/bin/install-agent-workflows" \ - --host claude --target "$CLAUDE_CONFIG_DIR" + --host claude --target "$CLAUDE_CONFIG_DIR" --channel development claude --plugin-dir "$CE_SOURCE" ``` @@ -161,7 +161,7 @@ with that same `CODEX_HOME`: ```bash export CODEX_HOME="$HOME/.codex/profiles/ce-pilot" -"$AGENT_WORKFLOWS_SOURCE/bin/install-agent-workflows" --host codex +"$AGENT_WORKFLOWS_SOURCE/bin/install-agent-workflows" --host codex --channel development codex plugin marketplace add "$CE_SOURCE" codex plugin add compound-engineering@compound-engineering-plugin codex diff --git a/docs/getting-started.md b/docs/getting-started.md index d5b1da182..26776ef01 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -48,12 +48,19 @@ throughout. ### Route A — Flat Installer (Used In This Guide) -Clone the pack once, then install it into the agent host you use: +For a human-reviewed stable installation, use the +[exact-release bootstrap](release-channel.md#install-update-and-roll-back). +It verifies an immutable annotated release tag and its approval receipt before +copying content. Use the installed agent home as the `--shared` root in Step 2. + +The captured walkthrough below deliberately uses the **development channel**: +it follows your mutable source clone and does not imply stable release approval. +Clone the pack once, then opt in explicitly: ```bash git clone https://github.com/shakacode/agent-workflows "$HOME/src/agent-workflows" cd "$HOME/src/agent-workflows" -bin/install-agent-workflows --host codex +bin/install-agent-workflows --host codex --channel development ``` Use `--host claude` for Claude Code instead. The installer copies skills, @@ -95,10 +102,10 @@ agent-workflows-status --host codex ``` ```text -UP_TO_DATE version=0.1.0 revision=c4b87520d9e4 delivery_mode=flat target=~/.codex +UP_TO_DATE channel=development release_ref=none version=0.1.0 exact_commit= delivery_mode=flat target=~/.codex ``` -`UP_TO_DATE` means the installed pack matches your clone. The `revision` value +`UP_TO_DATE` means the installed pack matches your clone. The `exact_commit` value will match whatever commit you cloned. That is the whole install. No signing keys, trust anchors, or extra setup are @@ -111,17 +118,20 @@ If you would rather use your host's own plugin manager, install the pack as the `scw` plugin instead. In Claude Code, type two commands inside the host: ```text -/plugin marketplace add shakacode/agent-workflows +/plugin marketplace add shakacode/agent-workflows@vX.Y.Z /plugin install scw@agent-workflows ``` Skills then appear under the plugin prefix — the `verify` skill used in -Step 3 shows up as `/scw:verify`. In Codex, from your shell: +Step 3 shows up as `/scw:verify`. Replace `vX.Y.Z` with the exact approved stable +release; Claude's relative-source plugin comes from that pinned marketplace. -```bash -codex plugin marketplace add shakacode/agent-workflows -codex plugin add scw@agent-workflows -``` +For stable Codex skills, use the verified +[copy bootstrap](release-channel.md#install-update-and-roll-back) in Route A. +The current native Codex URL route is **development/unverified**: marketplace +`--ref` does not pin the separately fetched plugin code. See +[Native Plugin Paths](installation-and-upgrades.md#native-plugin-paths) if you +deliberately want that development route. Two things to know about Route B: @@ -130,9 +140,15 @@ Two things to know about Route B: - The plugin route delivers only the skills. It does not put the helper commands this guide uses (`agent-workflow-seam-doctor`, `agent-workflows-status`, `upgrade-agent-workflows`) on your `PATH`. To get - those alongside a native plugin, run the installer in companion mode - (`bin/install-agent-workflows --host --delivery-mode plugin-companion`), - which installs the helpers without a second copy of the skills. + those alongside the enabled release-pinned Claude native `scw` plugin, first + follow the + [exact-release bootstrap](release-channel.md#install-update-and-roll-back). + In its final `"$source/bin/install-agent-workflows"` invocation, select + `--host claude` and add `--delivery-mode plugin-companion`. + This installs the helpers without a second copy of the skills; do not run an + installer from a mutable clone before completing the bootstrap. + A stable companion receipt covers only copied assets; it does not make the + native Codex plugin stable. See [Native Plugin Paths](installation-and-upgrades.md#native-plugin-paths) for the full details. The rest of this guide assumes Route A. @@ -563,7 +579,9 @@ until you upgrade it. Two helper commands manage that lifecycle. clone. It prints one of four tokens, with matching exit codes so scripts can read it too: `UP_TO_DATE` (exit 0), `UPGRADE_AVAILABLE` (exit 1), `NOT_INSTALLED` (exit 2), and `CHECK_FAILED` (exit 3). By default it compares -only local state; add `--fetch` to also check the remote for new commits. +only local state; for this development walkthrough, add `--fetch` to also check +the remote for new commits. Stable updates instead require an explicit +`--release vX.Y.Z`; they never select the latest release automatically. Here is the day it matters, captured from a real run. The source clone has moved one commit ahead of the installed copy: @@ -581,10 +599,15 @@ differ. To upgrade, run the upgrade helper and name the repository you adopted in Step 2 so its seam gets re-validated against the new pack: ```bash -upgrade-agent-workflows --host codex --consumer-root ~/src/my-app +upgrade-agent-workflows --host codex --channel development --consumer-root ~/src/my-app ``` -The helper updates the source clone (skipped with `--no-fetch` when the +For a stable installation, use `--release vX.Y.Z` instead of +`--channel development`, selecting a newer release to update or an older one to +roll back. Neither operation implicitly changes channels; see the +[rollback limits](release-channel.md#install-update-and-roll-back). + +The development helper updates the source clone (skipped with `--no-fetch` when the clone is already current), backs up the existing install, reinstalls with the same modes you originally chose, then re-runs the seam doctor for each `--consumer-root`. Add `--dry-run` first to print the same @@ -622,7 +645,7 @@ list](installation-and-upgrades.md#troubleshooting): | `agent-workflows-status` prints `UPGRADE_AVAILABLE` | Run `upgrade-agent-workflows` as shown above, or manually update the source clone and reinstall. ([details](installation-and-upgrades.md#troubleshooting)) | | `Auto host detection found both Codex and Claude homes` | You have both hosts installed, so rerun the command with an explicit `--host codex` or `--host claude`. ([details](installation-and-upgrades.md#troubleshooting)) | | `DELIVERY_MODE_CONFLICT` | Both delivery routes are active and the pack refuses to guess which skill copy wins; keep exactly one — disable or remove the native `scw` plugin before a flat install, or use `--delivery-mode plugin-companion`. ([details](installation-and-upgrades.md#troubleshooting)) | -| `invalid byte sequence in US-ASCII` or other `Encoding::` errors from a Ruby helper | An older install is running under a non-UTF-8 locale (`LANG=C` / `LC_ALL=C`, common in CI and headless agents); the pack's Ruby tools now read UTF-8 regardless of locale, so run `upgrade-agent-workflows --host ` to pick up the fix. ([details](installation-and-upgrades.md#troubleshooting)) | +| `invalid byte sequence in US-ASCII` or other `Encoding::` errors from a Ruby helper | An older install is running under a non-UTF-8 locale (`LANG=C` / `LC_ALL=C`, common in CI and headless agents); select an explicit `--release vX.Y.Z` or `--channel development` with `upgrade-agent-workflows --host `. ([details](installation-and-upgrades.md#troubleshooting)) | | The agent cannot find an installed skill | Check `agent-workflows-status --host ` says `UP_TO_DATE`, then restart the agent host so it reloads its skill directory. | ## Where To Go Next diff --git a/docs/installation-and-upgrades.md b/docs/installation-and-upgrades.md index 13f256483..e9b76d96b 100644 --- a/docs/installation-and-upgrades.md +++ b/docs/installation-and-upgrades.md @@ -130,25 +130,24 @@ unprefixed. Claude Code therefore exposes `skills/verify/SKILL.md` as as the UI display name without changing the `scw` install or namespace identifier. -Install the Claude Code plugin from the repository marketplace: +For Claude, first complete the [native release verification steps](release-channel.md#native-plugins) +to bind the release receipt, tag object, and installed commit. Then use the +repository marketplace pinned to that release: ```text -/plugin marketplace add shakacode/agent-workflows +/plugin marketplace add shakacode/agent-workflows@vX.Y.Z /plugin install scw@agent-workflows ``` -The Claude plugin deliberately omits an explicit `version`. Claude therefore -uses the Git commit SHA as the plugin version, so every commit on the -marketplace's tracked branch is updateable without maintaining duplicate -release numbers. Enable auto-update for the `agent-workflows` marketplace in -Claude's **Plugins → Marketplaces** UI when the installation should follow that -branch automatically; third-party marketplace auto-update is disabled by -default. Claude checks after startup and may delay the check by up to ten -minutes. Run `/reload-plugins` to load an installed update in the current -session, or start a new session. +The Claude plugin deliberately omits an explicit `version`, so Claude identifies +plugin revisions by their Git commit SHA. Stable installations must pin the +marketplace to the exact release tag; enable branch auto-update only for an +explicit development installation. -For Codex, point the current marketplace or plugin-source flow at this cloned or -released source pack and select `scw`: +For stable Codex skills, use the verified +[copy bootstrap](release-channel.md#install-update-and-roll-back). +The current native Codex URL route is **development/unverified**, not a stable +release install. Only if you deliberately want that development route: ```bash codex plugin marketplace add shakacode/agent-workflows @@ -157,7 +156,8 @@ codex plugin add scw@agent-workflows The Codex catalog lives at `.agents/plugins/marketplace.json`. Its URL source lets Codex cache the repository root as the plugin root without duplicating or -relocating `skills/`. +relocating `skills/`. Marketplace `--ref` pins only catalog metadata: this URL +entry has no plugin ref or SHA, so Codex separately fetches its default branch. Existing Codex native-plugin users must first remove the old `agent-workflows` plugin entry, refresh its marketplace, and reinstall it as `scw`. Do not keep @@ -172,15 +172,27 @@ Native manifests are deliberately source-pack metadata: consumer repository commands, labels, branches, changelog rules, CI policy, and review gates still come from that repository's `AGENTS.md` seam and `.agents/` contract. +Claude appends the tag to the GitHub shorthand and loads the plugin from the +pinned marketplace's relative source. If a host version cannot pin that initial +add, use the copy-mode stable installer or stop with `UNKNOWN`; do not execute +a mutable branch first. This does not apply to the current Codex URL entry. See +[Stable Release Channel](release-channel.md). + ## Native Plugin And Host Installer Boundaries The native paths do not replace installer-managed companion assets. With the -native `scw` plugin enabled, install those assets without flat skills: - -```bash -bin/install-agent-workflows --host claude --delivery-mode plugin-companion -bin/install-agent-workflows --host codex --delivery-mode plugin-companion -``` +release-pinned Claude native `scw` plugin enabled, first follow the +[exact-release bootstrap](release-channel.md#install-update-and-roll-back). +In its final `"$source/bin/install-agent-workflows"` invocation, select +`--host claude` and add `--delivery-mode plugin-companion` to +install those assets without flat skills. Do not run an installer from a mutable +clone before completing the bootstrap. + +A stable companion receipt covers only copied assets; it does not make the +native Codex plugin stable. +The current Codex URL plugin remains development/unverified regardless of the +companion channel. For stable Codex skills, disable the native plugin and use +the verified flat copy bootstrap instead. Native plugin installation does not install helper binaries on `PATH`, write `/.agent-workflows-install.json`, or participate in status and upgrade @@ -201,29 +213,32 @@ ruby bin/codex-plugin-manifest-check ## Install -Clone the source pack once: +For a first stable installation, use the +[authenticated bootstrap](release-channel.md#install-update-and-roll-back). +Obtain the verifier pin from an independently trusted copy of that guide; +verify the pin and release provenance before executing any selected-tag code. -```bash -git clone https://github.com/shakacode/agent-workflows "$HOME/src/agent-workflows" -cd "$HOME/src/agent-workflows" -``` +The stable installer retrieves the fixed +`agent-workflows-release-receipt.json` asset for the exact release from GitHub +and verifies it again after the independently authenticated bootstrap. Public +GitHub release metadata binds the receipt's server-side +SHA-256, size, GitHub Actions publisher, and exact tag; workflow-run and approval-history +metadata bind the canonical repository, exact tagged head, release workflow, +successful run ID/attempt, actor, protected environment, and independent +reviewer. The installer materializes the pack only after these checks pass. The +public API requires no installer credential; unavailable, rate-limited, +malformed, or mismatched evidence and a local self-asserted receipt fail closed. -Install for Codex: +Install another host from the already verified exact checkout. For Claude Code: ```bash -bin/install-agent-workflows --host codex -``` - -Install for Claude Code: - -```bash -bin/install-agent-workflows --host claude +"$source/bin/install-agent-workflows" --host claude --source "$source" --release "$release" ``` Install into an explicit shared agent home: ```bash -bin/install-agent-workflows --host codex --target "$HOME/.agents" +"$source/bin/install-agent-workflows" --host codex --target "$HOME/.agents" --source "$source" --release "$release" ``` A clean Codex or Claude installation can plan and launch ordinary batches as @@ -233,11 +248,13 @@ lifecycle state. Model/effort values are advisory preferences, while any host/model/effort observations are optional, host-exposed metadata with field-granular `UNKNOWN` for unavailable values. -Install companion assets for an already-enabled native plugin: +After the exact-release bootstrap, install companion assets for an +already-enabled release-pinned Claude native plugin: ```bash -bin/install-agent-workflows \ - --host codex \ +"$source/bin/install-agent-workflows" \ + --host claude --source "$source" \ + --release "$release" \ --delivery-mode plugin-companion ``` @@ -249,13 +266,17 @@ Modified, mismatched, ambiguous, and unowned paths are preserved; the migration stops with exact manual cleanup guidance. Unrelated skill names are never removed. -Then initialize and validate the seam from a consumer repository: +Then initialize and validate the seam from a consumer repository against the +installed exact-release target (use `$HOME/.claude` for a Claude install): ```bash cd /path/to/consumer/repo -agent-workflow-seam-doctor --init --shared "$HOME/src/agent-workflows" +agent-workflow-seam-doctor --init --shared "$HOME/.codex" ``` +Only development-channel installs should point `--shared` at a mutable source +checkout. + The initializer detects only unambiguous root binstubs or exact JavaScript scripts with one recognized package-manager lockfile. If it reports fail-closed wrapper guidance, configure the generated wrappers or rerun with @@ -265,7 +286,7 @@ For local development on this pack, symlink mode keeps the installed skills pointing at the clone: ```bash -bin/install-agent-workflows --host codex --mode symlink +bin/install-agent-workflows --host codex --mode symlink --channel development ``` ## Full Stack Contributor Setup @@ -278,7 +299,9 @@ bin/agent-stack sync ``` `agent-stack` is ShakaCode-specific stack tooling, not part of the generic -workflow-pack install path for consumer repositories. +workflow-pack install path for consumer repositories. Invoking `sync` is an +explicit development-channel choice: it follows the three repositories' `main` +branches and passes `--channel development` to the workflow installer. It keeps editable source checkouts in `~/src`, private runtime configuration under `~/.agent-workflows`, compatibility symlinks under `~/codex/agent-repos`, @@ -484,6 +507,7 @@ The installer writes: - `/docs/user-facing-coordination.md` - `/docs/writing-style.md` - `/docs/writing-style-asd-ste100.md` +- `/docs/release-channel.md` - `/docs/solutions/*` - `/bin/agent-workflow-seam-doctor` - `/bin/agent-workflow-writing-style` @@ -492,6 +516,7 @@ The installer writes: - `/bin/agent-workflows-delivery-state` - `/bin/agent-workflows-doctor` - `/bin/agent-workflows-refresh` +- `/bin/agent-workflows-release` - `/bin/agent-workflows-status` - `/bin/agent-workflows-trust-audit` - `/bin/install-agent-workflows` @@ -504,7 +529,8 @@ files already present in the target agent home, including generic consumer-owned docs under `/docs`. The metadata file records host, artifact mode, skill delivery mode, source -clone, pack version, source revision, branch, remote, and install time. Copy +clone, pack version, channel, release ref, annotated-tag object, exact peeled +source revision, development branch, remote, and install time. Copy installs also record `managed_skill_copy_fingerprints`, `managed_pack_doc_copy_fingerprints`, `managed_pack_helper_copy_fingerprints`, and `managed_pack_root_copy_fingerprints`, @@ -528,7 +554,7 @@ installed `agent-workflows` pack only. Check the installed pack against the source clone recorded at install time: ```bash -agent-workflows-status --host codex +agent-workflows-status --host codex --release vX.Y.Z ``` Check a specific install and source: @@ -536,7 +562,8 @@ Check a specific install and source: ```bash agent-workflows-status \ --target "$HOME/.codex" \ - --source "$HOME/src/agent-workflows" + --source "$HOME/src/agent-workflows" \ + --release vX.Y.Z ``` Stable status tokens: @@ -548,9 +575,10 @@ Stable status tokens: | `NOT_INSTALLED` | 2 | Target has no `.agent-workflows-install.json`. | | `CHECK_FAILED` | 3 | The check could not safely determine status. | -Use `--json` for machine-readable output. Use `--fetch` only when you want a -network check against `origin`; without `--fetch`, status compares against the -current local source clone. Status also reports `delivery_mode`, native plugin +Use `--json` for machine-readable output. Status always identifies `channel`, +`release_ref`, and the full exact commit. Use `--fetch` only when you want a +network check against `origin`; stable checks fetch tags while development +checks follow the selected branch. Status also reports `delivery_mode`, native plugin evidence, and flat-skill inventory. A collision, ambiguous native state, or an invalid companion layout returns `CHECK_FAILED` with cleanup guidance. @@ -588,15 +616,17 @@ Upgrade the source clone, reinstall the pack, and validate a consumer repo seam: ```bash upgrade-agent-workflows \ --host codex \ + --release vX.Y.Z \ --consumer-root /path/to/consumer/repo ``` -For an already-updated local source clone, skip the network step: +For an already-updated local source clone, skip the Git fetch: ```bash upgrade-agent-workflows \ --host codex \ --source "$HOME/src/agent-workflows" \ + --release vX.Y.Z \ --consumer-root /path/to/consumer/repo \ --no-fetch ``` @@ -604,40 +634,48 @@ upgrade-agent-workflows \ Preview without mutating the install: ```bash -upgrade-agent-workflows --host codex --dry-run +upgrade-agent-workflows --host codex --release vX.Y.Z --dry-run ``` Upgrade behavior: 1. Resolve target and source from arguments or install metadata. -2. Fetch and fast-forward the source clone unless `--no-fetch` is set. +2. Fetch tags for stable or fast-forward the explicitly selected development + branch unless `--no-fetch` is set. 3. Back up the target install. 4. Reinstall with the recorded or requested artifact and delivery modes. -5. Run `agent-workflow-seam-doctor --root --shared ` for - every `--consumer-root`. +5. Run `agent-workflow-seam-doctor --root --shared ` for + every stable `--consumer-root`, using only installed exact-release content. + Development-channel upgrades continue to validate against their explicit + mutable ``. 6. Restore the previous install if reinstall or seam validation fails. -The command prints `UPGRADE_COMPLETE` on success and `ROLLBACK_COMPLETE` when it -restores the prior install after a failed upgrade. Rollback restores the prior -delivery mode and skill layout. `upgrade-agent-workflows` never installs or -updates the native plugin itself. +The command prints `UPGRADE_COMPLETE` for a newer stable version and +`ROLLBACK_COMPLETE` when an explicitly selected older stable version is +installed. A failed operation also restores the prior delivery mode and skill +layout before exiting. `upgrade-agent-workflows` never installs or updates the +native plugin itself and never silently changes channels. ## Verification After Upgrade -For the shared pack itself: +For a stable installed pack itself: ```bash -cd "$HOME/src/agent-workflows" -bin/validate +agent-workflows-status --host codex --release vX.Y.Z ``` For each active consumer repo: ```bash cd /path/to/consumer/repo -agent-workflow-seam-doctor --shared "$HOME/src/agent-workflows" +agent-workflow-seam-doctor --shared "$HOME/.codex" ``` +These stable checks use the installed exact-release helper, scanner, skills, +workflows, and documentation. Do not point stable post-install validation back +at the mutable source checkout. Development-channel contributors may instead +run the source checkout's `bin/validate` and pass that checkout to `--shared`. + Consumers that intentionally leave named, non-required CircleCI workflows on their provider approval hold may opt into the closed trusted-base policy: @@ -738,19 +776,22 @@ stale workflow instructions or that explicitly need the new process. ## Network And Privacy -`agent-workflows-status` does not contact the network unless `--fetch` is -provided. `upgrade-agent-workflows` fetches and fast-forwards the source clone by -default. Use `--no-fetch` when the source clone has already been updated or when -the session must avoid network access. +Checking the installed stable release uses local evidence unless `--fetch` is +provided. Checking a different stable release, including an upgrade dry run, +also verifies its published receipt and GitHub provenance before reporting it +as available. Installed-file integrity is checked against the installed release. +`upgrade-agent-workflows` fetches tags or the development branch by default. +`--no-fetch` skips that Git fetch; stable installation and alternate-release +checks still require network access to verify published release evidence. ## Troubleshooting -- `NOT_INSTALLED`: run `bin/install-agent-workflows --host ` or pass the +- `NOT_INSTALLED`: run `bin/install-agent-workflows --host --release vX.Y.Z` or pass the correct `--target`. - `CHECK_FAILED missing source root`: reinstall from a valid clone, or pass `--source /path/to/agent-workflows`. -- `UPGRADE_AVAILABLE`: run `upgrade-agent-workflows` or manually update the - source clone and reinstall. +- `UPGRADE_AVAILABLE`: run `upgrade-agent-workflows --release vX.Y.Z` with the + intended immutable version; development users must pass `--channel development`. - `Auto host detection found both Codex and Claude homes`: rerun with `--host codex` or `--host claude`. - `Refusing to replace non-symlink path`: symlink mode will not overwrite a real @@ -763,5 +804,5 @@ the session must avoid network access. - `invalid byte sequence in US-ASCII` or other `Encoding::` errors from a Ruby helper: an older install is running under a non-UTF-8 locale (`LANG=C` / `LC_ALL=C`, common in CI and headless agents). The pack Ruby tools now read - text as UTF-8 regardless of locale; run `upgrade-agent-workflows --host ` + text as UTF-8 regardless of locale; run `upgrade-agent-workflows --host --release vX.Y.Z` to pick up the fix. diff --git a/docs/problems-solved.md b/docs/problems-solved.md index 0e59a0ae9..2df2984b0 100644 --- a/docs/problems-solved.md +++ b/docs/problems-solved.md @@ -32,7 +32,7 @@ Codex and Claude Code are equal delivery targets for the same shared content: | Distribution layer | Codex | Claude Code | | --- | --- | --- | | Shared workflow source | The Source Pack's `skills/` and `workflows/` | The same `skills/` and `workflows/` | -| Host installer | `bin/install-agent-workflows --host codex` | `bin/install-agent-workflows --host claude` | +| Host installer | `bin/install-agent-workflows --host codex --release vX.Y.Z` | `bin/install-agent-workflows --host claude --release vX.Y.Z` | | Native plugin | `scw` through the Codex marketplace manifest | `scw` through the Claude marketplace manifest | | Repository contract | The repo-owned `.agents/` seam and `AGENTS.md` pointer | The same repo-owned `.agents/` seam and `AGENTS.md` pointer | | Status and upgrades | Shared helpers manage installer assets; Codex manages native-plugin updates | The same helpers manage installer assets; Claude Code manages native-plugin updates | diff --git a/docs/release-branching.md b/docs/release-branching.md index ea67e826a..e2a183bb8 100644 --- a/docs/release-branching.md +++ b/docs/release-branching.md @@ -21,3 +21,8 @@ If the repo uses release branches, `AGENTS.md` should specify: - which checks, audits, or human approvals are required in each phase. If release phase cannot be verified, report `UNKNOWN` and avoid auto-merge. + +This consumer-repository branch policy is distinct from the Agent Workflows +source pack's stable/development delivery channels. See +[Stable Release Channel](release-channel.md) for protected immutable `v*` tags, +the `stable-release` environment, exact-ref installation, and rollback. diff --git a/docs/release-channel.md b/docs/release-channel.md new file mode 100644 index 000000000..7eef91de4 --- /dev/null +++ b/docs/release-channel.md @@ -0,0 +1,206 @@ +# Stable Release Channel + +Agent Workflows has two deliberately separate delivery channels: + +| Channel | Selector | Intended use | +| --- | --- | --- | +| Stable | exact annotated `vX.Y.Z` tag | human-reviewed installations, updates, and rollback | +| Development | explicit `--channel development` | maintainers who intentionally follow a mutable branch | + +Neither installer nor upgrader silently crosses between channels. Reinstall +explicitly when changing channels. Stable mode is copy-only because a symlink +would resume following mutable checkout state. + +## Repository Protection Required Before The First Release + +Repository administrators must configure both protections before promoting a +stable release: + +1. A repository ruleset targeting `refs/tags/v*` that restricts tag creation to + the protected release workflow and prevents tag updates and deletion. Tags are annotated + and immutable; cryptographic tag signatures are not required or checked. +2. A protected GitHub Actions environment named `stable-release` with required + human maintainers, self-review prevention, and no administrator bypass. The + approving reviewer must not be the change author or the release initiator. + +The workflow cannot safely substitute for those repository settings. If either +protection is absent or uncertain, release status is `UNKNOWN` and promotion +must stop. + +## Promotion Contract + +Finalize and merge the release version metadata first: `VERSION` and +`.codex-plugin/plugin.json` must agree on `X.Y.Z`. Claude's manifest omits +`version` so its host uses the pinned commit identity. Any declared Claude +version must agree too. No version edits may follow candidate verification. + +Dispatch the `Release` workflow from a branch at that **exact final candidate +commit**, with `release=vX.Y.Z`, the full `candidate_sha`, and the merged release +PR's `change_author`. The workflow requires its definition, run head, and checked +out tree to match that SHA. It then runs the complete `CI=true bin/validate` +with read-only permissions and no release environment or write credentials. +Installer, upgrade, rollback, stack, and all other validation suites must run; +partial coverage or a failing suite blocks promotion. Ordinary selected PR +checks cannot substitute for this run. Main pushes also retain full validation +between releases. + +Only after this job succeeds does the separate `stable-release` environment +request human approval. The reviewer inspects the exact candidate SHA and full +validation evidence, and must be neither the change author nor release +initiator. After approval is verified, the workflow creates the annotated tag, +checks its object ID, peeled commit, and version, and publishes the release. +It never updates or deletes an existing tag. A failed publication after tagging +requires maintainer investigation and a fresh version; rerunning cannot replace +an immutable tag. Cryptographic signatures are not required. + +Successful promotion publishes `agent-workflows-release-receipt.json`. The +receipt binds full verification before tagging, the exact candidate commit, +stable channel, release ref, annotated-tag object, peeled commit, protected +environment, human reviewer, change author, workflow actor, recording time, +repository, workflow path, and exact workflow run ID, attempt, and URL. Keep the +receipt as a GitHub Release asset; the successful workflow run and release asset +are the durable evidence. Protection settings must be verified separately; this +workflow does not configure them or claim they exist. + +## Updating A Fork + +For routine fork updates, prefer the latest upstream **verified release tag** +over moving `main`. Verify the upstream receipt and bind the fetched annotated +tag object and peeled commit before preparing the update. Open an update PR +that merges that exact upstream commit into the fork while retaining fork +changes, then run the fork's required checks against the resulting commit. +Record the upstream tag, commit, receipt, and fork check results in the PR. + +Upstream verification covers upstream content only; it does not attest the +fork's extra commits or conflict resolutions. Publishing the fork's own stable +release requires its own full verification of the final candidate after version +metadata, independent human approval, immutable tag, and receipt. Following +upstream `main` remains an explicit development choice. + +## Install, Update, And Roll Back + +Authenticate the bootstrap verifier before executing any selected-release code. +Use the SHA-256 pin below from an independently trusted copy of this guide; +never obtain its expected value from the unverified tag, its files, or its +release metadata. If that trusted pin is unavailable, stop. A verifier change +requires review and a new pin in the trusted guide; a mismatch must not be +bypassed. This authenticates the verifier, which then authenticates the release +receipt and protected workflow before the installer can run. + +```bash +release=vX.Y.Z +source="$HOME/src/agent-workflows" +( + set -euo pipefail + bootstrap_tmp="$(mktemp -d)" + trap 'rm -rf -- "$bootstrap_tmp"' EXIT + expected_verifier_sha256="9c15e93e693b3bdeec100ea05355cbd3548c785cd8337183d83656243710dfda" + git clone --no-checkout --filter=blob:none https://github.com/shakacode/agent-workflows "$source" + git -C "$source" fetch origin "refs/tags/$release:refs/tags/$release" + tag_object="$(git -C "$source" rev-parse --verify "refs/tags/$release")" + candidate="$(git -C "$source" rev-parse --verify "refs/tags/$release^{commit}")" + git -C "$source" show "$candidate:bin/agent-workflows-release" > "$bootstrap_tmp/verifier.rb" + ruby -rdigest -e ' + abort "Untrusted bootstrap verifier; stop" unless + Digest::SHA256.file(ARGV.fetch(1)).hexdigest == ARGV.fetch(0) + ' "$expected_verifier_sha256" "$bootstrap_tmp/verifier.rb" + ruby "$bootstrap_tmp/verifier.rb" verify-published \ + --root "$source" --release "$release" --approved-commit "$candidate" \ + --expected-tag-object "$tag_object" --repository shakacode/agent-workflows + git -C "$source" checkout --detach "$candidate" + "$source/bin/install-agent-workflows" --host codex --source "$source" --release "$release" +) +``` + +Use `--host claude` for Claude Code. Before materializing candidate content, the +bootstrap executes only verifier bytes authenticated by the independent pin. +The verified installer then repeats release verification before copying files. It verifies the annotated tag, downloads the fixed +receipt asset, and reads the public GitHub REST metadata for the release, +workflow run, and environment approval history. Verification binds the +server-reported asset SHA-256 and GitHub Actions publisher, canonical repository, exact release +tag and head, release workflow path, successful run ID and attempt, workflow +actor, and independent `stable-release` reviewer. Public metadata needs no API +credential; unavailable, malformed, rate-limited, or mismatched evidence fails +closed. Candidate files are extracted and copied only after this trust binding +succeeds. A local tag or self-asserted receipt without that evidence fails +closed. +Run metadata is fetched for the receipt's exact positive run attempt, so a +later rerun cannot substitute its result. Approval history remains bound to the +same run; missing historical evidence still fails closed. +Install metadata records `channel`, `release_ref`, `tag_object`, and the full +peeled `source_revision`. + +Successful stable install guidance uses the installed agent home as the +`--shared` root. The temporary exact-release materialization is removed after +installation and is never presented as a reusable validation path. + +Updates and rollbacks use the same explicit operation: + +```bash +upgrade-agent-workflows --host codex --release vX.Y.Z +``` + +Choose a newer tag to update or an older tag to roll back. The helper never +selects “latest” and never changes between stable and development implicitly. + +In-place rollback requires a compatible instruction surface. If a skill or +workflow root from the prior copy installation would remain active but is absent +from the selected release, installation stops before replacing common files +with `STABLE_INSTRUCTION_SURFACE_CONFLICT`. It also stops when the prior exact +revision is unavailable for checking that inventory. The installer does not +recursively delete these roots or discard user changes. Normal rollback with +the same skill/workflow roots remains supported; unchanged obsolete managed +documents and helpers are reconciled safely. + +For an incompatible rollback, install the selected release into a separate +clean target with `--target /path/to/new-agent-home`, verify it, and explicitly +configure the host to use that home. Preserve the previous home and do not load +both instruction sets together. An explicit development-symlink to stable-copy +reinstall remains supported when its managed skills fit the selected release. +Omitted managed skill, document, or helper links, including links to uncommitted +paths in the recorded development source, cause the same pre-replacement refusal. +Unrelated user links are preserved; the source checkout is not modified. + +Stack synchronization refuses to overwrite an installed stable target with its +development checkout. Use the explicit release upgrader for stable updates. + +For deliberate branch-following development, run the selected checkout's own +installer (`--source` cannot relabel a different development checkout): + +```bash +bin/install-agent-workflows --host codex --channel development +upgrade-agent-workflows --host codex --channel development +``` + +## Native Plugins + +Claude's relative-source native plugin can use the immutable marketplace ref. +First verify that release through the copy bootstrap into a separate staging +home; retain its recorded `release_ref`, `tag_object`, and `source_revision`. +Before enabling the native plugin, confirm its installed Git revision equals +that peeled commit. If the host cannot expose and preserve that identity, use +verified copy mode or report `UNKNOWN`. A tag string by itself is not evidence +of approval. Do not first add a branch-backed marketplace and later ask it to +switch revisions. + +```text +/plugin marketplace add shakacode/agent-workflows@vX.Y.Z +/plugin install scw@agent-workflows +``` + +Claude's GitHub shorthand appends `@vX.Y.Z`. Confirm the host can pin the initial +fetch before installation. If it cannot, use the copy-mode installer or stop +with `UNKNOWN` rather than executing a mutable branch first. + +For stable Codex skills, use the verified +[copy bootstrap](#install-update-and-roll-back). The current native Codex URL +route is **development/unverified**: marketplace `--ref` pins only the catalog, +not the separately fetched plugin code. The `scw` URL entry has no plugin ref or +SHA and therefore fetches the default branch. A stable companion receipt covers +only copied assets and does not make the native Codex plugin stable. Disable the +native plugin before installing stable flat skills. + +Use `agent-workflows-status`, `agent-workflows-doctor`, and +`agent-workflows-trust-audit --install-metadata /.agent-workflows-install.json` +to report the installed channel, release ref, and exact commit. In companion +mode these describe the copied assets, not approval of the native plugin code. diff --git a/docs/repository-supply-chain.md b/docs/repository-supply-chain.md index 9778ec6fd..1284e4e46 100644 --- a/docs/repository-supply-chain.md +++ b/docs/repository-supply-chain.md @@ -11,31 +11,34 @@ place for a mandatory independent human review. Automated reviews remain advisor They can find defects, but they do not establish that a stable release was inspected and approved by a human maintainer. -The repository does not have a stable release channel yet. Issue -[agent-workflows#296](https://github.com/shakacode/agent-workflows/issues/296) -tracks signed, immutable `vX.Y.Z` releases; exact-ref installs and upgrades; -release receipts; and human approval bound to the exact promoted commit. Until -that ships, `main`, the native marketplaces, and the default upgrade helper are -development channels. Do not present them as human-reviewed releases. +The stable channel uses protected, immutable, annotated `vX.Y.Z` tags; exact-ref +installs, updates, and rollbacks; a protected `stable-release` environment; and +a durable receipt binding independent human approval to the exact peeled +commit. Cryptographic tag signatures are not required or checked. See +[Stable Release Channel](release-channel.md). +Release operators must dispatch the workflow from the exact candidate commit; +the workflow rejects a definition or checkout from another commit. Full +validation runs after final version metadata and before protected human approval, +tag creation, and stable publication. Selected PR checks do not replace this +release gate; main pushes retain full checks between releases. This distinction keeps routine development fast. It also gives cautious users a -clear boundary: use the future stable channel, while maintainers who intentionally +clear boundary: use the stable channel, while maintainers who intentionally want branch-tip changes can choose the development channel. -## Installing and upgrading today +## Installing And Upgrading -There is no supported human-reviewed install or upgrade path today. If that -provenance is required, do not install from `main` or use the default upgrader; -wait for the immutable stable channel tracked in issue #296. A live checkout can -change while an installer reads it, so a shell recipe cannot substitute for an -immutable reviewed artifact. +Stable operations name the exact tag: `install-agent-workflows --release +vX.Y.Z` and `upgrade-agent-workflows --release vX.Y.Z`. The installer verifies +and materializes the tagged tree before executing its installer. Updates and +rollbacks never select “latest” or silently cross into development. For intentional development-channel use, do not pipe remote content into a shell. Clone the expected repository, verify its `origin`, inspect the checkout -to the degree appropriate for development, and run the installer locally. The -install receipt records the source revision, but it is not a release -attestation. `upgrade-agent-workflows` fetches and fast-forwards the recorded -source branch by default; this remains convenient development behavior, not a +to the degree appropriate for development, and run the installer locally with +`--channel development`. The install receipt records the source revision, but +it is not a release attestation. Development upgrades likewise require +`--channel development`; this remains convenient development behavior, not a reviewed stable update. Native plugin updates remain controlled by the host marketplace. Do not enable diff --git a/docs/source-pack-glossary.md b/docs/source-pack-glossary.md index 6d6feb352..a0d3197c7 100644 --- a/docs/source-pack-glossary.md +++ b/docs/source-pack-glossary.md @@ -24,7 +24,8 @@ contract files. _Avoid_: plugin settings, global config. **Host Installer Path**: -The existing `bin/install-agent-workflows --host ` route that installs +The `bin/install-agent-workflows --host --release vX.Y.Z` stable route, +or explicitly selected `--channel development` route, that installs skills, workflows, helper binaries, and metadata into a Codex or Claude home. _Avoid_: legacy path. diff --git a/skills/pr-batch/bin/task-review-loop-test.rb b/skills/pr-batch/bin/task-review-loop-test.rb index 99baf02c7..d41162718 100755 --- a/skills/pr-batch/bin/task-review-loop-test.rb +++ b/skills/pr-batch/bin/task-review-loop-test.rb @@ -94,6 +94,7 @@ def test_flat_install_ships_the_schema_and_canonical_finding_validator Dir.mkdir(target) _stdout, stderr, status = Open3.capture3( INSTALLER, + "--channel", "development", "--host", "codex", "--target", target, "--mode", "copy", @@ -125,6 +126,7 @@ def test_flat_install_refuses_an_unowned_schema_directory_symlink _stdout, stderr, status = Open3.capture3( INSTALLER, + "--channel", "development", "--host", "codex", "--target", target, "--mode", "copy", @@ -147,6 +149,7 @@ def test_flat_install_refuses_a_newly_colliding_personal_finding_validator _stdout, stderr, status = Open3.capture3( INSTALLER, + "--channel", "development", "--host", "codex", "--target", target, "--mode", "copy", @@ -174,6 +177,7 @@ def test_flat_copy_upgrade_from_a_non_git_source_uses_the_recorded_helper_finger validator = File.join(source, "bin/validate-review-findings") _stdout, stderr, status = Open3.capture3( installer, + "--channel", "development", "--host", "codex", "--target", target, "--mode", "copy", @@ -184,6 +188,7 @@ def test_flat_copy_upgrade_from_a_non_git_source_uses_the_recorded_helper_finger File.open(validator, "a") { |file| file.write("\n# non-git helper v2\n") } _stdout, stderr, status = Open3.capture3( installer, + "--channel", "development", "--host", "codex", "--target", target, "--mode", "copy", diff --git a/test/agent_doctor/workflows_cli_integration_test.rb b/test/agent_doctor/workflows_cli_integration_test.rb index 45b65c434..d498279b7 100644 --- a/test/agent_doctor/workflows_cli_integration_test.rb +++ b/test/agent_doctor/workflows_cli_integration_test.rb @@ -163,7 +163,9 @@ def write_status_helper puts JSON.generate( "status" => "UP_TO_DATE", "host" => host, "target" => target, "source" => source, "installed_version" => "1.0.0", "installed_revision" => "abc", "available_version" => "1.0.0", - "available_revision" => "abc", "checked_remote" => false, "reason" => nil, "guidance" => nil, + "available_revision" => "abc", "channel" => "development", "release_ref" => nil, + "available_release_ref" => nil, "exact_commit" => "abc", + "checked_remote" => false, "reason" => nil, "guidance" => nil, "delivery_mode" => "flat", "native" => nil, "flat" => nil ) RUBY diff --git a/test/agent_doctor/workflows_component_test.rb b/test/agent_doctor/workflows_component_test.rb index 8c8a56e3c..bf205381c 100644 --- a/test/agent_doctor/workflows_component_test.rb +++ b/test/agent_doctor/workflows_component_test.rb @@ -116,6 +116,10 @@ def status_payload(status, overrides = {}) "installed_revision" => nil, "available_version" => "0.1.0", "available_revision" => nil, + "channel" => "development", + "release_ref" => nil, + "available_release_ref" => nil, + "exact_commit" => nil, "checked_remote" => false, "reason" => nil, "guidance" => nil,