diff --git a/.github/workflows/skill-release.yml b/.github/workflows/controlled-skill-release.yml similarity index 92% rename from .github/workflows/skill-release.yml rename to .github/workflows/controlled-skill-release.yml index 2bae043f..e53c375c 100644 --- a/.github/workflows/skill-release.yml +++ b/.github/workflows/controlled-skill-release.yml @@ -1,9 +1,6 @@ name: Skill Release on: - push: - tags: - - '*-v[0-9]*.[0-9]*.[0-9]*' pull_request: paths: - 'skills/**' @@ -11,15 +8,52 @@ on: - '!skills/clawsec-feed/advisories/feed.json.sig' - '!skills/clawsec-suite/advisories/feed.json' - '!skills/clawsec-suite/advisories/feed.json.sig' - - '.github/workflows/skill-release.yml' + - '.github/workflows/controlled-skill-release.yml' + - '.github/workflows/create-skill-release-tag.yml' - 'scripts/ci/**' - 'scripts/test-skill-*.mjs' workflow_dispatch: inputs: + operation: + description: 'Controlled stable release or stable ClawHub republish' + required: true + default: republish_clawhub + type: choice + options: + - republish_clawhub + - controlled_release tag: - description: 'Tag to re-publish to ClawHub (e.g., clawsec-suite-v0.0.10)' + description: 'Stable package-qualified tag (for example, clawsec-suite-v0.2.0)' required: true type: string + tag_object_oid: + description: 'Controlled release annotated tag-object ID' + required: false + type: string + peeled_commit_oid: + description: 'Controlled release peeled commit ID' + required: false + type: string + protected_main_commit: + description: 'Commit verified at protected main before tag creation' + required: false + type: string + release_attempt_id: + description: 'Durable repository_id:controller_run_id attempt ID' + required: false + type: string + tag_ruleset_id: + description: 'Operator-reviewed tag ruleset ID' + required: false + type: string + tag_ruleset_updated_at: + description: 'Exact updated_at of the operator-reviewed tag ruleset' + required: false + type: string + release_app_id: + description: 'Dedicated release GitHub App ID bound into the tag proof' + required: false + type: string permissions: read-all @@ -27,7 +61,7 @@ permissions: read-all # .github/clawhub-cli/package-lock.json — bump it there. concurrency: - group: skill-release-${{ github.ref }} + group: skill-release-${{ github.event.pull_request.number || inputs.tag || github.ref }} cancel-in-progress: false jobs: @@ -1100,8 +1134,8 @@ jobs: stable-publication-policy: if: >- - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || - github.event_name == 'workflow_dispatch' + github.event_name == 'workflow_dispatch' && + (inputs.operation == 'controlled_release' || inputs.operation == 'republish_clawhub') runs-on: ubuntu-latest permissions: contents: read @@ -1111,6 +1145,13 @@ jobs: version: ${{ steps.policy.outputs.version }} publication_eligible: ${{ steps.policy.outputs.publication_eligible }} reason_code: ${{ steps.policy.outputs.reason_code }} + tag_object_oid: ${{ steps.policy.outputs.tag_object_oid }} + peeled_commit_oid: ${{ steps.policy.outputs.peeled_commit_oid }} + protected_main_commit: ${{ steps.policy.outputs.protected_main_commit }} + release_attempt_id: ${{ steps.policy.outputs.release_attempt_id }} + tag_ruleset_id: ${{ steps.policy.outputs.tag_ruleset_id }} + tag_ruleset_updated_at: ${{ steps.policy.outputs.tag_ruleset_updated_at }} + release_app_id: ${{ steps.policy.outputs.release_app_id }} steps: - name: Checkout protected default-branch policy uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -1127,11 +1168,33 @@ jobs: - name: Enforce final-version public tag policy id: policy env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EVENT_NAME: ${{ github.event_name }} + GITHUB_TOKEN: ${{ github.token }} + OPERATION: ${{ inputs.operation }} + PEELED_COMMIT_OID: ${{ inputs.peeled_commit_oid }} + PROTECTED_MAIN_COMMIT: ${{ inputs.protected_main_commit }} + RELEASE_APP_ID: ${{ inputs.release_app_id }} + RELEASE_ATTEMPT_ID: ${{ inputs.release_attempt_id }} + RELEASE_TAG_RULESET_ID: ${{ inputs.tag_ruleset_id }} + RELEASE_TAG_RULESET_UPDATED_AT: ${{ inputs.tag_ruleset_updated_at }} + REPOSITORY: ${{ github.repository }} REQUESTED_REF: ${{ github.ref }} - REQUESTED_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + REQUESTED_TAG: ${{ inputs.tag }} + TAG_OBJECT_OID: ${{ inputs.tag_object_oid }} run: | set -euo pipefail + test "$EVENT_NAME" = "workflow_dispatch" + test "$DEFAULT_BRANCH" = "main" + test "$REQUESTED_REF" = "refs/heads/$DEFAULT_BRANCH" + case "$OPERATION" in + controlled_release|republish_clawhub) ;; + *) + echo "::error::Unsupported release operation" + exit 1 + ;; + esac + decision="$(node scripts/ci/stable_tag_policy.mjs --tag "$REQUESTED_TAG")" printf '%s\n' "$decision" | jq -e \ '.publication_eligible == true and .stable_authorized == false' >/dev/null @@ -1141,10 +1204,34 @@ jobs: version="$(printf '%s\n' "$decision" | jq -r '.version')" reason_code="$(printf '%s\n' "$decision" | jq -r '.reason_code')" - if [ "$EVENT_NAME" = "push" ]; then - test "$REQUESTED_REF" = "refs/tags/${validated_tag}" - else - git show-ref --verify --quiet "refs/tags/${validated_tag}" + git show-ref --verify --quiet "refs/tags/${validated_tag}" + + verified_tag_object_oid="" + verified_peeled_commit_oid="" + verified_protected_main_commit="" + verified_release_attempt_id="" + verified_tag_ruleset_id="" + verified_tag_ruleset_updated_at="" + verified_release_app_id="" + if [ "$OPERATION" = "controlled_release" ]; then + provenance="$(node scripts/ci/controlled_tag_creation.mjs verify-release \ + --tag "$validated_tag" \ + --tag-object-oid "$TAG_OBJECT_OID" \ + --peeled-commit-oid "$PEELED_COMMIT_OID" \ + --protected-main-commit "$PROTECTED_MAIN_COMMIT" \ + --release-attempt-id "$RELEASE_ATTEMPT_ID" \ + --repository "$REPOSITORY" \ + --ruleset-id "$RELEASE_TAG_RULESET_ID" \ + --ruleset-updated-at "$RELEASE_TAG_RULESET_UPDATED_AT" \ + --app-id "$RELEASE_APP_ID")" + printf '%s\n' "$provenance" | jq -e '.provenance_verified == true' >/dev/null + verified_tag_object_oid="$(printf '%s\n' "$provenance" | jq -r '.tag_object_oid')" + verified_peeled_commit_oid="$(printf '%s\n' "$provenance" | jq -r '.peeled_commit_oid')" + verified_protected_main_commit="$(printf '%s\n' "$provenance" | jq -r '.protected_main_commit')" + verified_release_attempt_id="$(printf '%s\n' "$provenance" | jq -r '.release_attempt_id')" + verified_tag_ruleset_id="$(printf '%s\n' "$provenance" | jq -r '.tag_ruleset_id')" + verified_tag_ruleset_updated_at="$(printf '%s\n' "$provenance" | jq -r '.tag_ruleset_updated_at')" + verified_release_app_id="$(printf '%s\n' "$provenance" | jq -r '.release_app_id')" fi { @@ -1153,10 +1240,20 @@ jobs: printf 'version=%s\n' "$version" printf 'publication_eligible=true\n' printf 'reason_code=%s\n' "$reason_code" + printf 'tag_object_oid=%s\n' "$verified_tag_object_oid" + printf 'peeled_commit_oid=%s\n' "$verified_peeled_commit_oid" + printf 'protected_main_commit=%s\n' "$verified_protected_main_commit" + printf 'release_attempt_id=%s\n' "$verified_release_attempt_id" + printf 'tag_ruleset_id=%s\n' "$verified_tag_ruleset_id" + printf 'tag_ruleset_updated_at=%s\n' "$verified_tag_ruleset_updated_at" + printf 'release_app_id=%s\n' "$verified_release_app_id" } >> "$GITHUB_OUTPUT" release-tag: - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + if: >- + github.event_name == 'workflow_dispatch' && + inputs.operation == 'controlled_release' && + needs.stable-publication-policy.outputs.publication_eligible == 'true' needs: stable-publication-policy runs-on: ubuntu-latest permissions: @@ -1187,9 +1284,40 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: refs/tags/${{ needs.stable-publication-policy.outputs.tag }} + ref: ${{ needs.stable-publication-policy.outputs.peeled_commit_oid }} persist-credentials: false + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + + - name: Reverify controlled tag provenance before release writes + env: + GITHUB_TOKEN: ${{ github.token }} + PEELED_COMMIT_OID: ${{ needs.stable-publication-policy.outputs.peeled_commit_oid }} + PROTECTED_MAIN_COMMIT: ${{ needs.stable-publication-policy.outputs.protected_main_commit }} + RELEASE_APP_ID: ${{ needs.stable-publication-policy.outputs.release_app_id }} + RELEASE_ATTEMPT_ID: ${{ needs.stable-publication-policy.outputs.release_attempt_id }} + RELEASE_TAG_RULESET_ID: ${{ needs.stable-publication-policy.outputs.tag_ruleset_id }} + RELEASE_TAG_RULESET_UPDATED_AT: ${{ needs.stable-publication-policy.outputs.tag_ruleset_updated_at }} + REPOSITORY: ${{ github.repository }} + TAG: ${{ needs.stable-publication-policy.outputs.tag }} + TAG_OBJECT_OID: ${{ needs.stable-publication-policy.outputs.tag_object_oid }} + run: | + set -euo pipefail + node scripts/ci/controlled_tag_creation.mjs verify-release \ + --tag "$TAG" \ + --tag-object-oid "$TAG_OBJECT_OID" \ + --peeled-commit-oid "$PEELED_COMMIT_OID" \ + --protected-main-commit "$PROTECTED_MAIN_COMMIT" \ + --release-attempt-id "$RELEASE_ATTEMPT_ID" \ + --repository "$REPOSITORY" \ + --ruleset-id "$RELEASE_TAG_RULESET_ID" \ + --ruleset-updated-at "$RELEASE_TAG_RULESET_UPDATED_AT" \ + --app-id "$RELEASE_APP_ID" \ + | jq -e '.provenance_verified == true' >/dev/null + - name: Verify signing key consistency (repo + docs) run: ./scripts/ci/verify_signing_key_consistency.sh @@ -1289,11 +1417,6 @@ jobs: echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT echo "clawhub_slug=${CLAWHUB_SLUG}" >> $GITHUB_OUTPUT - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 20 - - name: Validate npx skills install docs run: node scripts/ci/validate_skill_install_docs.mjs --skills "${{ steps.parse.outputs.skill_path }}" @@ -1829,8 +1952,8 @@ jobs: # It publishes the verified GitHub release payload, never the raw source directory. # Retriggerable: can be manually triggered for failed publishes. if: >- - github.event_name == 'push' && - startsWith(github.ref, 'refs/tags/') && + github.event_name == 'workflow_dispatch' && + inputs.operation == 'controlled_release' && needs.stable-publication-policy.outputs.publication_eligible == 'true' && needs.release-tag.outputs.publish_clawhub == 'true' needs: [stable-publication-policy, release-tag] @@ -1850,7 +1973,7 @@ jobs: if: needs.release-tag.outputs.publish_clawhub == 'true' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: refs/tags/${{ needs.stable-publication-policy.outputs.tag }} + ref: ${{ needs.stable-publication-policy.outputs.peeled_commit_oid }} persist-credentials: false - name: Setup Node @@ -2022,10 +2145,10 @@ jobs: echo "✓ Verified published ClawHub package matches the signed release payload" republish-clawhub: - # Manual workflow to republish a specific tag to ClawHub - # Usage: Go to Actions → Skill Release → Run workflow → Enter tag name + # Manual current-policy path to republish a stable tag to ClawHub. if: >- github.event_name == 'workflow_dispatch' && + inputs.operation == 'republish_clawhub' && needs.stable-publication-policy.outputs.publication_eligible == 'true' needs: stable-publication-policy runs-on: ubuntu-latest diff --git a/.github/workflows/create-skill-release-tag.yml b/.github/workflows/create-skill-release-tag.yml new file mode 100644 index 00000000..d2b077c0 --- /dev/null +++ b/.github/workflows/create-skill-release-tag.yml @@ -0,0 +1,283 @@ +name: Create Skill Release Tag +run-name: Create ${{ inputs.package_name }} v${{ inputs.version }} from ${{ inputs.protected_main_commit }} + +on: + workflow_dispatch: + inputs: + package_name: + description: 'Skill package directory name (for example, clawsec-suite)' + required: true + type: string + version: + description: 'Final SemVer only (for example, 0.2.0)' + required: true + type: string + protected_main_commit: + description: 'Exact 40-character commit currently at protected main' + required: true + type: string + +permissions: read-all + +concurrency: + group: controlled-skill-tag-${{ inputs.package_name }}-v${{ inputs.version }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.preflight.outputs.tag }} + protected_main_commit: ${{ steps.preflight.outputs.protected_main_commit }} + release_attempt_id: ${{ steps.preflight.outputs.release_attempt_id }} + preflight: ${{ steps.preflight.outputs.preflight }} + steps: + - name: Require protected default-branch dispatch and enabled controller + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + RELEASE_ENABLED: ${{ vars.CLAWSEC_CONTROLLED_RELEASE_ENABLED }} + WORKFLOW_REF: ${{ github.ref }} + RELEASE_APP_CLIENT_ID: ${{ vars.CLAWSEC_RELEASE_APP_CLIENT_ID }} + RELEASE_APP_ID: ${{ vars.CLAWSEC_RELEASE_APP_ID }} + RELEASE_TAG_RULESET_ID: ${{ vars.CLAWSEC_RELEASE_TAG_RULESET_ID }} + RELEASE_TAG_RULESET_UPDATED_AT: ${{ vars.CLAWSEC_RELEASE_TAG_RULESET_UPDATED_AT }} + run: | + set -euo pipefail + test "$DEFAULT_BRANCH" = "main" + test "$WORKFLOW_REF" = "refs/heads/$DEFAULT_BRANCH" + test "$RELEASE_ENABLED" = "true" + test -n "$RELEASE_APP_CLIENT_ID" + test -n "$RELEASE_APP_ID" + test -n "$RELEASE_TAG_RULESET_ID" + test -n "$RELEASE_TAG_RULESET_UPDATED_AT" + + - name: Checkout current protected policy + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + path: policy + fetch-depth: 0 + persist-credentials: false + + - name: Checkout requested protected-main source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.protected_main_commit }} + path: source + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + + - name: Validate source and preflight server state without writes + id: preflight + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GITHUB_TOKEN: ${{ github.token }} + PACKAGE_NAME: ${{ inputs.package_name }} + PROTECTED_MAIN_COMMIT: ${{ inputs.protected_main_commit }} + RELEASE_APP_ID: ${{ vars.CLAWSEC_RELEASE_APP_ID }} + RELEASE_ATTEMPT_ID: ${{ github.repository_id }}:${{ github.run_id }} + RELEASE_TAG_RULESET_ID: ${{ vars.CLAWSEC_RELEASE_TAG_RULESET_ID }} + RELEASE_TAG_RULESET_UPDATED_AT: ${{ vars.CLAWSEC_RELEASE_TAG_RULESET_UPDATED_AT }} + REPOSITORY: ${{ github.repository }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + decision="$(node policy/scripts/ci/controlled_tag_creation.mjs preflight \ + --package "$PACKAGE_NAME" \ + --version "$VERSION" \ + --source-commit "$PROTECTED_MAIN_COMMIT" \ + --release-attempt-id "$RELEASE_ATTEMPT_ID" \ + --repository "$REPOSITORY" \ + --ruleset-id "$RELEASE_TAG_RULESET_ID" \ + --ruleset-updated-at "$RELEASE_TAG_RULESET_UPDATED_AT" \ + --app-id "$RELEASE_APP_ID" \ + --default-branch "$DEFAULT_BRANCH" \ + --repo-root "$GITHUB_WORKSPACE/source")" + printf '%s\n' "$decision" | jq -e \ + '.source_checkout_verified == true and (.preflight == "new_tag" or .preflight == "exact_retry")' \ + >/dev/null + { + printf 'tag=%s\n' "$(printf '%s\n' "$decision" | jq -r '.tag')" + printf 'protected_main_commit=%s\n' \ + "$(printf '%s\n' "$decision" | jq -r '.protected_main_commit')" + printf 'release_attempt_id=%s\n' \ + "$(printf '%s\n' "$decision" | jq -r '.release_attempt_id')" + printf 'preflight=%s\n' "$(printf '%s\n' "$decision" | jq -r '.preflight')" + } >> "$GITHUB_OUTPUT" + + write-annotated-tag: + needs: validate + runs-on: ubuntu-latest + environment: clawsec-stable-release + permissions: + contents: read + outputs: + tag: ${{ steps.write.outputs.tag }} + tag_object_oid: ${{ steps.write.outputs.tag_object_oid }} + peeled_commit_oid: ${{ steps.write.outputs.peeled_commit_oid }} + protected_main_commit: ${{ steps.write.outputs.protected_main_commit }} + release_attempt_id: ${{ steps.write.outputs.release_attempt_id }} + operation: ${{ steps.write.outputs.operation }} + tag_ruleset_id: ${{ steps.write.outputs.tag_ruleset_id }} + tag_ruleset_updated_at: ${{ steps.write.outputs.tag_ruleset_updated_at }} + release_app_id: ${{ steps.write.outputs.release_app_id }} + steps: + - name: Checkout current protected policy + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + path: policy + fetch-depth: 0 + persist-credentials: false + + - name: Checkout requested protected-main source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.validate.outputs.protected_main_commit }} + path: source + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + + - name: Recheck enabled controller after environment approval + env: + RELEASE_ENABLED: ${{ vars.CLAWSEC_CONTROLLED_RELEASE_ENABLED }} + run: | + set -euo pipefail + if [[ "$RELEASE_ENABLED" != "true" ]]; then + echo "Controlled stable releases are disabled after environment approval." >&2 + exit 1 + fi + + - name: Mint repository-scoped release App token + id: release-app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.CLAWSEC_RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.CLAWSEC_RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + + - name: Preflight ruleset and create or exactly resume annotated tag + id: write + env: + CONTROLLED_TAG_TOKEN: ${{ steps.release-app-token.outputs.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PACKAGE_NAME: ${{ inputs.package_name }} + PROTECTED_MAIN_COMMIT: ${{ needs.validate.outputs.protected_main_commit }} + RELEASE_APP_ID: ${{ vars.CLAWSEC_RELEASE_APP_ID }} + RELEASE_ATTEMPT_ID: ${{ needs.validate.outputs.release_attempt_id }} + RELEASE_TAG_RULESET_ID: ${{ vars.CLAWSEC_RELEASE_TAG_RULESET_ID }} + RELEASE_TAG_RULESET_UPDATED_AT: ${{ vars.CLAWSEC_RELEASE_TAG_RULESET_UPDATED_AT }} + REPOSITORY: ${{ github.repository }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + result="$(node policy/scripts/ci/controlled_tag_creation.mjs create \ + --package "$PACKAGE_NAME" \ + --version "$VERSION" \ + --source-commit "$PROTECTED_MAIN_COMMIT" \ + --release-attempt-id "$RELEASE_ATTEMPT_ID" \ + --repository "$REPOSITORY" \ + --default-branch "$DEFAULT_BRANCH" \ + --ruleset-id "$RELEASE_TAG_RULESET_ID" \ + --ruleset-updated-at "$RELEASE_TAG_RULESET_UPDATED_AT" \ + --app-id "$RELEASE_APP_ID" \ + --repo-root "$GITHUB_WORKSPACE/source")" + printf '%s\n' "$result" | jq -e \ + '(.operation == "created" or .operation == "resumed") and + (.tag_object_oid | test("^[0-9a-f]{40}$")) and + (.peeled_commit_oid | test("^[0-9a-f]{40}$"))' >/dev/null + { + printf 'tag=%s\n' "$(printf '%s\n' "$result" | jq -r '.tag')" + printf 'tag_object_oid=%s\n' \ + "$(printf '%s\n' "$result" | jq -r '.tag_object_oid')" + printf 'peeled_commit_oid=%s\n' \ + "$(printf '%s\n' "$result" | jq -r '.peeled_commit_oid')" + printf 'protected_main_commit=%s\n' \ + "$(printf '%s\n' "$result" | jq -r '.protected_main_commit')" + printf 'release_attempt_id=%s\n' \ + "$(printf '%s\n' "$result" | jq -r '.release_attempt_id')" + printf 'operation=%s\n' "$(printf '%s\n' "$result" | jq -r '.operation')" + printf 'tag_ruleset_id=%s\n' \ + "$(printf '%s\n' "$result" | jq -r '.tag_ruleset_id')" + printf 'tag_ruleset_updated_at=%s\n' \ + "$(printf '%s\n' "$result" | jq -r '.tag_ruleset_updated_at')" + printf 'release_app_id=%s\n' \ + "$(printf '%s\n' "$result" | jq -r '.release_app_id')" + } >> "$GITHUB_OUTPUT" + + dispatch-release: + needs: write-annotated-tag + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Checkout current protected policy + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + + - name: Reverify exact tuple and dispatch current release workflow + id: dispatch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GITHUB_TOKEN: ${{ github.token }} + PEELED_COMMIT_OID: ${{ needs.write-annotated-tag.outputs.peeled_commit_oid }} + PROTECTED_MAIN_COMMIT: ${{ needs.write-annotated-tag.outputs.protected_main_commit }} + RELEASE_ATTEMPT_ID: ${{ needs.write-annotated-tag.outputs.release_attempt_id }} + RELEASE_APP_ID: ${{ needs.write-annotated-tag.outputs.release_app_id }} + REPOSITORY: ${{ github.repository }} + RELEASE_TAG_RULESET_ID: ${{ needs.write-annotated-tag.outputs.tag_ruleset_id }} + RELEASE_TAG_RULESET_UPDATED_AT: ${{ needs.write-annotated-tag.outputs.tag_ruleset_updated_at }} + TAG: ${{ needs.write-annotated-tag.outputs.tag }} + TAG_OBJECT_OID: ${{ needs.write-annotated-tag.outputs.tag_object_oid }} + run: | + set -euo pipefail + result="$(node scripts/ci/controlled_tag_creation.mjs dispatch-release \ + --tag "$TAG" \ + --tag-object-oid "$TAG_OBJECT_OID" \ + --peeled-commit-oid "$PEELED_COMMIT_OID" \ + --protected-main-commit "$PROTECTED_MAIN_COMMIT" \ + --release-attempt-id "$RELEASE_ATTEMPT_ID" \ + --repository "$REPOSITORY" \ + --ruleset-id "$RELEASE_TAG_RULESET_ID" \ + --ruleset-updated-at "$RELEASE_TAG_RULESET_UPDATED_AT" \ + --app-id "$RELEASE_APP_ID" \ + --default-branch "$DEFAULT_BRANCH")" + printf '%s\n' "$result" | jq -e \ + '.provenance_verified == true and (.downstream_workflow_run_id | type == "number")' \ + >/dev/null + { + printf 'workflow_run_id=%s\n' \ + "$(printf '%s\n' "$result" | jq -r '.downstream_workflow_run_id')" + printf 'html_url=%s\n' "$(printf '%s\n' "$result" | jq -r '.downstream_html_url')" + } >> "$GITHUB_OUTPUT" + + - name: Record downstream release run + env: + DOWNSTREAM_HTML_URL: ${{ steps.dispatch.outputs.html_url }} + DOWNSTREAM_RUN_ID: ${{ steps.dispatch.outputs.workflow_run_id }} + run: | + { + printf 'Controlled release workflow run: [%s](%s)\n' \ + "$DOWNSTREAM_RUN_ID" "$DOWNSTREAM_HTML_URL" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/ci/controlled_tag_creation.mjs b/scripts/ci/controlled_tag_creation.mjs new file mode 100644 index 00000000..51480f93 --- /dev/null +++ b/scripts/ci/controlled_tag_creation.mjs @@ -0,0 +1,886 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { requireSkillPublication } from "./skill_installability.mjs"; +import { + evaluateStableTagPolicy, + loadLegacyPrereleaseInventory, +} from "./stable_tag_policy.mjs"; + +export const CONTROLLED_TAG_SCHEMA = "clawsec.controlled-tag/v1"; +export const CONTROLLED_RELEASE_WORKFLOW = "controlled-skill-release.yml"; +export const REQUIRED_TAG_RULESET_PATTERN = "refs/tags/*-v*"; + +const OBJECT_ID_PATTERN = /^[0-9a-f]{40}$/; +const PACKAGE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const ATTEMPT_ID_PATTERN = /^[1-9][0-9]*:[1-9][0-9]*$/; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const REQUIRED_RULE_TYPES = ["creation", "update", "deletion", "non_fast_forward"]; + +function fail(message) { + throw new Error(message); +} + +function requireString(value, name) { + if (typeof value !== "string" || value.length === 0) { + fail(`${name} must be a non-empty string`); + } + return value; +} + +function requireObjectId(value, name) { + requireString(value, name); + if (!OBJECT_ID_PATTERN.test(value)) { + fail(`${name} must be a lowercase 40-character Git object ID`); + } + return value; +} + +function requireAttemptId(value) { + requireString(value, "release attempt ID"); + if (!ATTEMPT_ID_PATTERN.test(value)) { + fail("release attempt ID must be :"); + } + return value; +} + +function requirePositiveInteger(value, name) { + const parsed = Number(requireString(String(value), name)); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + fail(`${name} must be a positive integer`); + } + return parsed; +} + +function requireRulesetUpdatedAt(value) { + requireString(value, "ruleset updated_at"); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) + || Number.isNaN(Date.parse(value)) + ) { + fail("ruleset updated_at must be an exact UTC RFC 3339 timestamp"); + } + return value; +} + +function requireRepository(value) { + requireString(value, "repository"); + if (!REPOSITORY_PATTERN.test(value)) { + fail("repository must be an owner/name pair"); + } + return value; +} + +function requireDefaultBranch(value) { + if (value !== "main") { + fail("controlled release requires the protected default branch to be main"); + } + return value; +} + +function canonicalTagMessage({ + repository, + packageName, + version, + tag, + sourceCommit, + releaseAttemptId, + rulesetId, + rulesetUpdatedAt, + appId, +}) { + return JSON.stringify({ + schema: CONTROLLED_TAG_SCHEMA, + repository, + package_name: packageName, + version, + tag, + protected_main_commit: sourceCommit, + release_attempt_id: releaseAttemptId, + tag_ruleset_id: rulesetId, + tag_ruleset_updated_at: rulesetUpdatedAt, + release_app_id: appId, + }); +} + +export function createControlledTagPlan({ + repository, + packageName, + version, + sourceCommit, + releaseAttemptId, + rulesetId, + rulesetUpdatedAt, + appId, + historicalTags, +}) { + requireRepository(repository); + requireString(packageName, "package name"); + if (!PACKAGE_PATTERN.test(packageName)) { + fail("package name must use lowercase alphanumeric kebab-case"); + } + requireString(version, "version"); + requireObjectId(sourceCommit, "protected main commit"); + requireAttemptId(releaseAttemptId); + const normalizedRulesetId = requirePositiveInteger(rulesetId, "ruleset ID"); + const normalizedRulesetUpdatedAt = requireRulesetUpdatedAt(rulesetUpdatedAt); + const normalizedAppId = requirePositiveInteger(appId, "GitHub App ID"); + if (!(historicalTags instanceof Set)) { + fail("historicalTags must be a validated Set"); + } + + const tag = `${packageName}-v${version}`; + const stableDecision = evaluateStableTagPolicy({ tag, historicalTags }); + if (!stableDecision.publication_eligible) { + fail(`stable tag policy denied ${tag}: ${stableDecision.reason_code}`); + } + if (stableDecision.package_name !== packageName || stableDecision.version !== version) { + fail("stable tag policy returned a mismatched package or version"); + } + + const tagMessage = canonicalTagMessage({ + repository, + packageName, + version, + tag, + sourceCommit, + releaseAttemptId, + rulesetId: normalizedRulesetId, + rulesetUpdatedAt: normalizedRulesetUpdatedAt, + appId: normalizedAppId, + }); + return { + schema: CONTROLLED_TAG_SCHEMA, + repository, + package_name: packageName, + version, + tag, + protected_main_commit: sourceCommit, + release_attempt_id: releaseAttemptId, + tag_ruleset_id: normalizedRulesetId, + tag_ruleset_updated_at: normalizedRulesetUpdatedAt, + release_app_id: normalizedAppId, + tag_message: tagMessage, + tag_message_sha256: createHash("sha256").update(tagMessage).digest("hex"), + }; +} + +function parseSkillMarkdownVersion(markdown, source) { + const normalized = markdown.replaceAll("\r\n", "\n"); + if (!normalized.startsWith("---\n")) { + fail(`${source} must start with YAML frontmatter`); + } + const end = normalized.indexOf("\n---\n", 4); + if (end === -1) { + fail(`${source} has unterminated YAML frontmatter`); + } + const matches = [...normalized.slice(4, end).matchAll(/^version:[ \t]*([^ \t\n]+)[ \t]*$/gm)]; + if (matches.length !== 1) { + fail(`${source} must declare exactly one frontmatter version`); + } + return matches[0][1]; +} + +function runGit(repoRoot, args) { + try { + return execFileSync("git", ["-C", repoRoot, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (error) { + const detail = String(error?.stderr ?? "").trim(); + fail(`Git source inspection failed${detail ? `: ${detail}` : ""}`); + } +} + +export async function validateSourceCheckout({ repoRoot, plan }) { + const root = path.resolve(requireString(repoRoot, "repository root")); + const topLevel = path.resolve(runGit(root, ["rev-parse", "--show-toplevel"])); + if (topLevel !== root) { + fail(`repository root mismatch: expected ${root}, observed ${topLevel}`); + } + const observedHead = runGit(root, ["rev-parse", "HEAD"]); + if (observedHead !== plan.protected_main_commit) { + fail("checked-out commit does not match the requested protected-main commit"); + } + const status = runGit(root, ["status", "--porcelain=v1", "--untracked-files=all"]); + if (status !== "") { + fail("source checkout is dirty; tracked, staged, and untracked changes are forbidden"); + } + + const skillDir = path.join(root, "skills", plan.package_name); + const skillJsonPath = path.join(skillDir, "skill.json"); + const skillMarkdownPath = path.join(skillDir, "SKILL.md"); + let metadata; + try { + metadata = JSON.parse(await readFile(skillJsonPath, "utf8")); + } catch (error) { + fail(`unable to load valid skill metadata from ${skillJsonPath}: ${error.message}`); + } + if (metadata?.name !== plan.package_name) { + fail("skill.json name must exactly match the package directory and release tag"); + } + if (metadata?.version !== plan.version) { + fail("skill.json version must exactly match the requested final version"); + } + requireSkillPublication(metadata, skillJsonPath); + + let markdown; + try { + markdown = await readFile(skillMarkdownPath, "utf8"); + } catch (error) { + fail(`unable to load ${skillMarkdownPath}: ${error.message}`); + } + if (parseSkillMarkdownVersion(markdown, skillMarkdownPath) !== plan.version) { + fail("SKILL.md frontmatter version must exactly match the requested final version"); + } + + return { + ...plan, + source_checkout_verified: true, + }; +} + +export function validateTagRuleset( + ruleset, + { repository, rulesetId, rulesetUpdatedAt, appId }, +) { + requireRepository(repository); + const expectedRulesetId = requirePositiveInteger(rulesetId, "ruleset ID"); + const expectedRulesetUpdatedAt = requireRulesetUpdatedAt(rulesetUpdatedAt); + const expectedAppId = requirePositiveInteger(appId, "GitHub App ID"); + if (!ruleset || typeof ruleset !== "object" || Array.isArray(ruleset)) { + fail("tag ruleset response must be an object"); + } + if (ruleset.id !== expectedRulesetId) { + fail("tag ruleset ID does not match the configured ruleset"); + } + if (ruleset.updated_at !== expectedRulesetUpdatedAt) { + fail("tag ruleset changed after the operator-reviewed ruleset proof"); + } + if (ruleset.source_type !== "Repository" || ruleset.source !== repository) { + fail("tag ruleset must be owned by the release repository named in the proof"); + } + if (ruleset.target !== "tag" || ruleset.enforcement !== "active") { + fail("tag ruleset must target tags with active enforcement"); + } + + const refName = ruleset.conditions?.ref_name; + const includes = refName?.include; + const excludes = refName?.exclude; + if (!Array.isArray(includes) || !Array.isArray(excludes)) { + fail("tag ruleset must expose ref_name include and exclude conditions"); + } + if (excludes.length !== 0) { + fail("tag ruleset must not exclude refs from release-tag protection"); + } + if (!includes.includes("~ALL") && !includes.includes(REQUIRED_TAG_RULESET_PATTERN)) { + fail(`tag ruleset must include ~ALL or ${REQUIRED_TAG_RULESET_PATTERN}`); + } + + const ruleTypes = new Set( + Array.isArray(ruleset.rules) ? ruleset.rules.map((rule) => rule?.type) : [], + ); + for (const ruleType of REQUIRED_RULE_TYPES) { + if (!ruleTypes.has(ruleType)) { + fail(`tag ruleset is missing required ${ruleType} protection`); + } + } + + let bypassActorVisibility = "redacted_operator_proof_required"; + if (Object.hasOwn(ruleset, "bypass_actors")) { + if (!Array.isArray(ruleset.bypass_actors) || ruleset.bypass_actors.length !== 1) { + fail("visible tag-ruleset policy must have exactly one bypass actor"); + } + const bypass = ruleset.bypass_actors[0]; + if ( + bypass?.actor_type !== "Integration" + || bypass?.actor_id !== expectedAppId + || bypass?.bypass_mode !== "always" + ) { + fail("the sole visible tag-ruleset bypass must be the dedicated release GitHub App"); + } + bypassActorVisibility = "api_verified"; + } + + return { + ruleset_id: expectedRulesetId, + ruleset_updated_at: expectedRulesetUpdatedAt, + app_id: expectedAppId, + active: true, + bypass_actor_visibility: bypassActorVisibility, + }; +} + +export class GitHubApiError extends Error { + constructor(message, { status, response } = {}) { + super(message); + this.name = "GitHubApiError"; + this.status = status; + this.response = response; + } +} + +export class GitHubApiClient { + constructor({ token, apiUrl = "https://api.github.com", fetchImpl = globalThis.fetch }) { + requireString(token, "GitHub API token"); + if (typeof fetchImpl !== "function") { + fail("GitHub API client requires fetch support"); + } + const parsedUrl = new URL(apiUrl); + if (!new Set(["https:", "http:"]).has(parsedUrl.protocol)) { + fail("GitHub API URL must use HTTP or HTTPS"); + } + this.token = token; + this.apiUrl = parsedUrl.href.replace(/\/$/, ""); + this.fetchImpl = fetchImpl; + } + + async request(apiPath, { method = "GET", body, expected = [200] } = {}) { + const response = await this.fetchImpl(`${this.apiUrl}${apiPath}`, { + method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2026-03-10", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const responseText = await response.text(); + let responseBody = null; + if (responseText !== "") { + try { + responseBody = JSON.parse(responseText); + } catch { + throw new GitHubApiError("GitHub API returned malformed JSON", { + status: response.status, + response: responseText.slice(0, 500), + }); + } + } + if (!expected.includes(response.status)) { + throw new GitHubApiError(`GitHub API request failed with status ${response.status}`, { + status: response.status, + response: responseBody, + }); + } + return { status: response.status, data: responseBody }; + } +} + +function repositoryApiPath(repository) { + requireRepository(repository); + const [owner, name] = repository.split("/"); + return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; +} + +async function readDefaultBranchHead(client, repository, defaultBranch) { + requireDefaultBranch(defaultBranch); + const { data } = await client.request( + `${repositoryApiPath(repository)}/git/ref/heads/${encodeURIComponent(defaultBranch)}`, + ); + return requireObjectId(data?.object?.sha, "observed default-branch head"); +} + +async function readTagRecord(client, repository, tag, { allowMissing = false } = {}) { + let refResponse; + try { + refResponse = await client.request( + `${repositoryApiPath(repository)}/git/ref/tags/${encodeURIComponent(tag)}`, + ); + } catch (error) { + if (allowMissing && error instanceof GitHubApiError && error.status === 404) { + return null; + } + throw error; + } + if (refResponse.data?.ref !== `refs/tags/${tag}`) { + fail("tag ref readback returned a mismatched or nested ref"); + } + if (refResponse.data?.object?.type !== "tag") { + fail("release tag must be an annotated tag object, not a lightweight tag"); + } + const tagObjectOid = requireObjectId(refResponse.data.object.sha, "tag-object ID"); + const { data: tagObject } = await client.request( + `${repositoryApiPath(repository)}/git/tags/${tagObjectOid}`, + ); + if (tagObject?.sha !== tagObjectOid) { + fail("annotated tag-object readback returned a mismatched object ID"); + } + if (tagObject?.object?.type !== "commit") { + fail("annotated release tag must peel directly to a commit"); + } + return { + tag: tagObject.tag, + message: tagObject.message, + tag_object_oid: tagObjectOid, + peeled_commit_oid: requireObjectId(tagObject.object.sha, "peeled commit ID"), + }; +} + +function verifyTagRecord(record, plan, expected = {}) { + if (!record) { + fail(`release tag ${plan.tag} does not exist`); + } + if (record.tag !== plan.tag) { + fail("annotated tag object contains the wrong tag name"); + } + if (record.message !== plan.tag_message) { + fail("annotated tag message does not match the controlled release tuple"); + } + if (record.peeled_commit_oid !== plan.protected_main_commit) { + fail("annotated tag peels to the wrong protected-main commit"); + } + if (expected.tagObjectOid && record.tag_object_oid !== expected.tagObjectOid) { + fail("tag-object ID does not match the dispatched release tuple"); + } + if (expected.peeledCommitOid && record.peeled_commit_oid !== expected.peeledCommitOid) { + fail("peeled commit does not match the dispatched release tuple"); + } + return record; +} + +async function loadVerifiedTagRecord( + client, + repository, + plan, + { allowMissing = false, expected = {} } = {}, +) { + const record = await readTagRecord(client, repository, plan.tag, { allowMissing }); + if (!record && allowMissing) { + return null; + } + return verifyTagRecord(record, plan, expected); +} + +async function requireCurrentMain(client, repository, defaultBranch, sourceCommit) { + const observed = await readDefaultBranchHead(client, repository, defaultBranch); + if (observed !== sourceCommit) { + fail("protected main changed or does not match the requested source commit"); + } + return observed; +} + +export async function createOrResumeControlledTag({ + client, + repository, + defaultBranch, + rulesetId, + appId, + plan, +}) { + requireRepository(repository); + if (plan.repository !== repository) { + fail("release plan repository does not match the GitHub API repository"); + } + if ( + plan.tag_ruleset_id !== requirePositiveInteger(rulesetId, "ruleset ID") + || plan.release_app_id !== requirePositiveInteger(appId, "GitHub App ID") + ) { + fail("release plan does not match the configured ruleset or GitHub App"); + } + requireDefaultBranch(defaultBranch); + const { data: ruleset } = await client.request( + `${repositoryApiPath(repository)}/rulesets/${encodeURIComponent(String(rulesetId))}`, + ); + const rulesetProof = validateTagRuleset(ruleset, { + repository, + rulesetId, + rulesetUpdatedAt: plan.tag_ruleset_updated_at, + appId, + }); + + const existing = await loadVerifiedTagRecord(client, repository, plan, { allowMissing: true }); + if (existing) { + return { ...plan, ...existing, ...rulesetProof, operation: "resumed" }; + } + + await requireCurrentMain( + client, + repository, + defaultBranch, + plan.protected_main_commit, + ); + + // This second server-side read is intentionally the last operation before the + // first write. A changed protected-main SHA must produce no tag/ref write. + await requireCurrentMain( + client, + repository, + defaultBranch, + plan.protected_main_commit, + ); + + const { data: createdTag } = await client.request( + `${repositoryApiPath(repository)}/git/tags`, + { + method: "POST", + expected: [201], + body: { + tag: plan.tag, + message: plan.tag_message, + object: plan.protected_main_commit, + type: "commit", + }, + }, + ); + const createdTagObjectOid = requireObjectId(createdTag?.sha, "created tag-object ID"); + if ( + createdTag?.tag !== plan.tag + || createdTag?.message !== plan.tag_message + || createdTag?.object?.type !== "commit" + || createdTag?.object?.sha !== plan.protected_main_commit + ) { + fail("created annotated tag object did not match the requested release tuple"); + } + + let refRace = false; + try { + await client.request(`${repositoryApiPath(repository)}/git/refs`, { + method: "POST", + expected: [201], + body: { + ref: `refs/tags/${plan.tag}`, + sha: createdTagObjectOid, + }, + }); + } catch (error) { + if (!(error instanceof GitHubApiError) || error.status !== 422) { + throw error; + } + refRace = true; + // Another rerun of this same workflow may have won the create-ref race. + // The exact tuple readback below is the only accepted recovery. + } + + const readback = await loadVerifiedTagRecord(client, repository, plan); + return { + ...plan, + ...readback, + ...rulesetProof, + operation: refRace ? "resumed" : "created", + }; +} + +export async function verifyControlledReleaseTuple({ + client, + repository, + plan, + tagObjectOid, + peeledCommitOid, +}) { + if (plan.repository !== repository) { + fail("release plan repository does not match the GitHub API repository"); + } + requireObjectId(tagObjectOid, "dispatched tag-object ID"); + requireObjectId(peeledCommitOid, "dispatched peeled-commit ID"); + if (peeledCommitOid !== plan.protected_main_commit) { + fail("protected-main commit and peeled-commit inputs must be identical"); + } + const record = await loadVerifiedTagRecord(client, repository, plan, { + expected: { tagObjectOid, peeledCommitOid }, + }); + return { ...plan, ...record, provenance_verified: true }; +} + +async function requireProtectedMainAncestry({ + client, + repository, + defaultBranch, + sourceCommit, +}) { + const currentHead = await readDefaultBranchHead(client, repository, defaultBranch); + if (currentHead === sourceCommit) { + return currentHead; + } + const { data: comparison } = await client.request( + `${repositoryApiPath(repository)}/compare/${sourceCommit}...${currentHead}`, + ); + if ( + comparison?.status !== "ahead" + || comparison?.merge_base_commit?.sha !== sourceCommit + ) { + fail("tagged commit is no longer an ancestor of protected main"); + } + return currentHead; +} + +export async function preflightControlledTag({ + client, + repository, + defaultBranch, + plan, +}) { + if (plan.repository !== repository) { + fail("release plan repository does not match the GitHub API repository"); + } + const existing = await loadVerifiedTagRecord(client, repository, plan, { allowMissing: true }); + if (existing) { + await requireProtectedMainAncestry({ + client, + repository, + defaultBranch, + sourceCommit: plan.protected_main_commit, + }); + return { ...plan, ...existing, preflight: "exact_retry" }; + } + await requireCurrentMain( + client, + repository, + defaultBranch, + plan.protected_main_commit, + ); + return { ...plan, preflight: "new_tag" }; +} + +export async function dispatchControlledRelease({ + client, + repository, + defaultBranch, + plan, + tagObjectOid, + peeledCommitOid, +}) { + const verified = await verifyControlledReleaseTuple({ + client, + repository, + plan, + tagObjectOid, + peeledCommitOid, + }); + await requireProtectedMainAncestry({ + client, + repository, + defaultBranch, + sourceCommit: plan.protected_main_commit, + }); + + const { data } = await client.request( + `${repositoryApiPath(repository)}/actions/workflows/${encodeURIComponent(CONTROLLED_RELEASE_WORKFLOW)}/dispatches`, + { + method: "POST", + expected: [200], + body: { + ref: requireDefaultBranch(defaultBranch), + inputs: { + operation: "controlled_release", + tag: plan.tag, + tag_object_oid: verified.tag_object_oid, + peeled_commit_oid: verified.peeled_commit_oid, + protected_main_commit: plan.protected_main_commit, + release_attempt_id: plan.release_attempt_id, + tag_ruleset_id: String(plan.tag_ruleset_id), + tag_ruleset_updated_at: plan.tag_ruleset_updated_at, + release_app_id: String(plan.release_app_id), + }, + }, + }, + ); + if (!Number.isSafeInteger(data?.workflow_run_id) || data.workflow_run_id <= 0) { + fail("workflow dispatch did not return a valid downstream run ID"); + } + if (typeof data?.run_url !== "string" || typeof data?.html_url !== "string") { + fail("workflow dispatch did not return downstream run URLs"); + } + return { + ...verified, + downstream_workflow_run_id: data.workflow_run_id, + downstream_run_url: data.run_url, + downstream_html_url: data.html_url, + }; +} + +function usage() { + return [ + "Usage:", + " All commands require --ruleset-id --ruleset-updated-at --app-id .", + " controlled_tag_creation.mjs plan --package --version --source-commit --release-attempt-id --repository --ruleset-id --ruleset-updated-at --app-id [--repo-root ]", + " controlled_tag_creation.mjs preflight --package --version --source-commit --release-attempt-id --repository --ruleset-id --ruleset-updated-at --app-id --default-branch main [--repo-root ]", + " controlled_tag_creation.mjs create --package --version --source-commit --release-attempt-id --repository --ruleset-id --ruleset-updated-at --app-id --default-branch main [--repo-root ]", + " controlled_tag_creation.mjs verify-release --tag --tag-object-oid --peeled-commit-oid --protected-main-commit --release-attempt-id --repository --ruleset-id --ruleset-updated-at --app-id ", + " controlled_tag_creation.mjs dispatch-release --tag --tag-object-oid --peeled-commit-oid --protected-main-commit --release-attempt-id --repository --ruleset-id --ruleset-updated-at --app-id --default-branch main", + ].join("\n"); +} + +function parseArguments(argv) { + if (argv.length === 0) { + fail(usage()); + } + const command = argv[0]; + const options = {}; + for (let index = 1; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined || value.startsWith("--")) { + fail(usage()); + } + const key = name.slice(2); + if (Object.hasOwn(options, key)) { + fail(`duplicate option: ${name}`); + } + options[key] = value; + } + return { command, options }; +} + +function requireOnlyOptions(options, required, optional = []) { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(options)) { + if (!allowed.has(key)) { + fail(`unknown option: --${key}`); + } + } + for (const key of required) { + if (!Object.hasOwn(options, key)) { + fail(`missing required option: --${key}`); + } + } +} + +async function planFromOptions(options, { validateCheckout = false } = {}) { + const { historicalTags } = await loadLegacyPrereleaseInventory(); + const plan = createControlledTagPlan({ + repository: options.repository, + packageName: options.package, + version: options.version, + sourceCommit: options["source-commit"], + releaseAttemptId: options["release-attempt-id"], + rulesetId: options["ruleset-id"], + rulesetUpdatedAt: options["ruleset-updated-at"], + appId: options["app-id"], + historicalTags, + }); + if (!validateCheckout) { + return plan; + } + return validateSourceCheckout({ + repoRoot: options["repo-root"] ?? ".", + plan, + }); +} + +async function planFromReleaseTuple(options) { + const separator = options.tag.lastIndexOf("-v"); + if (separator <= 0) { + fail("controlled release tag is not package-qualified"); + } + const { historicalTags } = await loadLegacyPrereleaseInventory(); + return createControlledTagPlan({ + repository: options.repository, + packageName: options.tag.slice(0, separator), + version: options.tag.slice(separator + 2), + sourceCommit: options["protected-main-commit"], + releaseAttemptId: options["release-attempt-id"], + rulesetId: options["ruleset-id"], + rulesetUpdatedAt: options["ruleset-updated-at"], + appId: options["app-id"], + historicalTags, + }); +} + +function apiClientFromEnvironment(tokenName) { + return new GitHubApiClient({ + token: process.env[tokenName], + apiUrl: process.env.GITHUB_API_URL ?? "https://api.github.com", + }); +} + +async function runCli(argv) { + const { command, options } = parseArguments(argv); + const planOptions = [ + "package", + "version", + "source-commit", + "release-attempt-id", + "repository", + "ruleset-id", + "ruleset-updated-at", + "app-id", + ]; + const releaseOptions = [ + "tag", + "tag-object-oid", + "peeled-commit-oid", + "protected-main-commit", + "release-attempt-id", + "repository", + "ruleset-id", + "ruleset-updated-at", + "app-id", + ]; + + if (command === "plan") { + requireOnlyOptions(options, planOptions, ["repo-root"]); + return planFromOptions(options, { validateCheckout: true }); + } + if (command === "preflight") { + requireOnlyOptions(options, [...planOptions, "default-branch"], ["repo-root"]); + const plan = await planFromOptions(options, { validateCheckout: true }); + return preflightControlledTag({ + client: apiClientFromEnvironment("GITHUB_TOKEN"), + repository: options.repository, + defaultBranch: options["default-branch"], + plan, + }); + } + if (command === "create") { + requireOnlyOptions( + options, + [...planOptions, "default-branch"], + ["repo-root"], + ); + const plan = await planFromOptions(options, { validateCheckout: true }); + return createOrResumeControlledTag({ + client: apiClientFromEnvironment("CONTROLLED_TAG_TOKEN"), + repository: options.repository, + defaultBranch: options["default-branch"], + rulesetId: options["ruleset-id"], + appId: options["app-id"], + plan, + }); + } + if (command === "verify-release") { + requireOnlyOptions(options, releaseOptions); + const plan = await planFromReleaseTuple(options); + return verifyControlledReleaseTuple({ + client: apiClientFromEnvironment("GITHUB_TOKEN"), + repository: options.repository, + plan, + tagObjectOid: options["tag-object-oid"], + peeledCommitOid: options["peeled-commit-oid"], + }); + } + if (command === "dispatch-release") { + requireOnlyOptions(options, [...releaseOptions, "default-branch"]); + const plan = await planFromReleaseTuple(options); + return dispatchControlledRelease({ + client: apiClientFromEnvironment("GITHUB_TOKEN"), + repository: options.repository, + defaultBranch: options["default-branch"], + plan, + tagObjectOid: options["tag-object-oid"], + peeledCommitOid: options["peeled-commit-oid"], + }); + } + fail(usage()); +} + +async function main() { + try { + const result = await runCli(process.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + process.stderr.write(`Controlled tag operation denied: ${error.message}\n`); + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/scripts/release-skill.sh b/scripts/release-skill.sh index 458037ae..66cef96c 100755 --- a/scripts/release-skill.sh +++ b/scripts/release-skill.sh @@ -1,383 +1,224 @@ #!/bin/bash -# Usage: ./scripts/release-skill.sh [--force-tag] -# Example: ./scripts/release-skill.sh clawsec-feed 1.1.0 +# Usage: ./scripts/release-skill.sh +# Example: ./scripts/release-skill.sh clawsec-suite 0.2.0 # -# This script ensures version consistency by: -# 1. Updating skill.json with the new version -# 2. Updating any hardcoded version URLs in skill.json and SKILL.md -# 3. Committing the changes -# 4. Creating the git tag (only on main/master branch) -# -# Branch-aware workflow: -# Feature branch: Updates versions, commits, pushes → CI validates build -# Main branch: Updates versions, commits, creates tag → push triggers release -# -# Use --force-tag to create a tag even when not on main/master. +# This is a preparation helper. It updates and commits a final version on a +# review branch. It never creates or pushes a tag and never creates a GitHub +# Release. After the reviewed change reaches protected main, use the +# "Create Skill Release Tag" workflow with that exact main commit. set -euo pipefail -# Parse arguments -FORCE_TAG=false -POSITIONAL_ARGS=() - -for arg in "$@"; do - case $arg in - --force-tag) - FORCE_TAG=true - ;; - *) - POSITIONAL_ARGS+=("$arg") - ;; - esac -done - -if [ "${#POSITIONAL_ARGS[@]}" -ne 2 ]; then - echo "Usage: $0 [--force-tag]" - echo "Example: $0 clawsec-feed 1.1.0" +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + echo "Example: $0 clawsec-suite 0.2.0" >&2 exit 1 fi -SKILL_NAME="${POSITIONAL_ARGS[0]}" -VERSION="${POSITIONAL_ARGS[1]}" +SKILL_NAME="$1" +VERSION="$2" SKILL_PATH="skills/$SKILL_NAME" +TAG="${SKILL_NAME}-v${VERSION}" -# Initialize variables -RELEASE_NOTES="" +if ! [[ "$SKILL_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "Error: skill name must use lowercase alphanumeric kebab-case." >&2 + exit 1 +fi + +for required_command in git jq node; do + if ! command -v "$required_command" >/dev/null 2>&1; then + echo "Error: $required_command is required." >&2 + exit 1 + fi +done -# Ensure we're on a branch (not detached HEAD) so release flow works from feature branches CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD || true)" if [ -z "$CURRENT_BRANCH" ]; then - echo "Error: Detached HEAD detected. Checkout a branch before running release." >&2 + echo "Error: detached HEAD detected. Checkout a review branch first." >&2 exit 1 fi - -# Determine if we're on a release branch (main/master) -IS_RELEASE_BRANCH=false -if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then - IS_RELEASE_BRANCH=true -fi - -# Security: Validate skill name to prevent path injection -# Only allow lowercase alphanumeric characters and hyphens -if ! [[ "$SKILL_NAME" =~ ^[a-z0-9-]+$ ]]; then - echo "Error: Invalid skill name. Only lowercase alphanumeric characters and hyphens are allowed." - echo "Example: clawsec-feed, prompt-agent, clawtributor" +if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then + echo "Error: version preparation must run on a review branch, not protected $CURRENT_BRANCH." >&2 exit 1 fi if [ ! -f "$SKILL_PATH/skill.json" ]; then - echo "Error: $SKILL_PATH/skill.json not found" + echo "Error: $SKILL_PATH/skill.json not found." >&2 exit 1 fi - -if ! command -v node >/dev/null 2>&1; then - echo "Error: Node.js is required to evaluate skill publication eligibility" >&2 +if [ ! -f "$SKILL_PATH/SKILL.md" ]; then + echo "Error: $SKILL_PATH/SKILL.md not found." >&2 exit 1 fi -TAG="${SKILL_NAME}-v${VERSION}" - -# This is the public release helper, not the private beta/RC candidate builder. -# Enforce the shared stable-tag policy before any source, Git, tag, or release mutation. +# The shared stable policy runs before any source or Git mutation. Beta and RC +# candidates belong to the private lab flow and never enter this helper. if ! node scripts/ci/stable_tag_policy.mjs --tag "$TAG" >/dev/null; then - echo "Error: Public release preparation requires a strict final SemVer tag." >&2 + echo "Error: public release preparation requires a strict final SemVer." >&2 echo "Build beta and RC candidates through the private lab-candidate flow." >&2 exit 1 fi -if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then - INSTALLABLE="$(node scripts/ci/skill_installability.mjs "$SKILL_PATH" --require-publication)" -else - INSTALLABLE="$(node scripts/ci/skill_installability.mjs "$SKILL_PATH")" -fi +INSTALLABLE="$(node scripts/ci/skill_installability.mjs "$SKILL_PATH")" -# Check for uncommitted changes in skill directory -if ! git diff --quiet "$SKILL_PATH/" 2>/dev/null; then - echo "Error: $SKILL_PATH/ has uncommitted changes. Please commit or stash them first." +# Prevent a scoped release commit from silently capturing unrelated staged, +# tracked, or untracked content. +if [ -n "$(git status --porcelain=v1 --untracked-files=all)" ]; then + echo "Error: repository must be globally clean before version preparation." >&2 exit 1 fi -echo "Releasing $SKILL_NAME version $VERSION" -echo "Branch: $CURRENT_BRANCH" -if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then - echo "Mode: Full release (will create tag)" -else - echo "Mode: Prep only (tag deferred until merge to main)" -fi -echo "=======================================" - -# Create a temporary directory for atomic operations -TEMP_DIR=$(mktemp -d) -trap 'rm -rf "$TEMP_DIR"' EXIT - -# Track files that need to be staged -FILES_TO_STAGE=() +TEMP_DIR="$(mktemp -d)" +ORIGINAL_HEAD="$(git rev-parse HEAD)" +APPLY_STARTED=false +PREPARATION_COMPLETED=false +PREPARED_FILES=() +TARGET_FILES=() +BACKUP_FILES=() + +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + + if [ "$APPLY_STARTED" = "true" ] && [ "$PREPARATION_COMPLETED" != "true" ]; then + current_head="$(git rev-parse HEAD 2>/dev/null || true)" + if [ "$current_head" = "$ORIGINAL_HEAD" ]; then + echo "Version preparation failed; restoring the original clean tree." >&2 + rollback_failed=false + for index in "${!TARGET_FILES[@]}"; do + if ! cp -p "${BACKUP_FILES[$index]}" "${TARGET_FILES[$index]}"; then + rollback_failed=true + fi + done + if ! git add -- "${TARGET_FILES[@]}"; then + rollback_failed=true + fi + if [ -n "$(git status --porcelain=v1 --untracked-files=all)" ]; then + echo "Error: repository is not clean after automatic rollback." >&2 + rollback_failed=true + fi + if [ "$rollback_failed" = "true" ]; then + echo "Error: automatic rollback was incomplete; inspect the listed skill files." >&2 + fi + else + echo "Warning: HEAD changed during preparation; refusing to overwrite the new commit." >&2 + fi + fi -# Update version in skill.json -echo "Updating $SKILL_PATH/skill.json version to $VERSION..." -if ! jq --arg v "$VERSION" '.version = $v' "$SKILL_PATH/skill.json" > "$TEMP_DIR/skill.json"; then - echo "Error: Failed to update version in skill.json" + rm -rf "$TEMP_DIR" + exit "$status" +} +trap cleanup EXIT + +handle_signal() { + local signal_status="$1" + trap - HUP INT TERM + exit "$signal_status" +} +trap 'handle_signal 129' HUP +trap 'handle_signal 130' INT +trap 'handle_signal 143' TERM + +queue_prepared_file() { + PREPARED_FILES+=("$1") + TARGET_FILES+=("$2") +} + +echo "Preparing $SKILL_NAME version $VERSION on review branch $CURRENT_BRANCH" + +if ! jq --arg version "$VERSION" '.version = $version' \ + "$SKILL_PATH/skill.json" > "$TEMP_DIR/skill.json"; then + echo "Error: failed to update $SKILL_PATH/skill.json." >&2 exit 1 fi -mv "$TEMP_DIR/skill.json" "$SKILL_PATH/skill.json" -FILES_TO_STAGE+=("$SKILL_PATH/skill.json") -# Update any hardcoded version URLs in skill.json (openclaw.feed_url pattern) -if jq -e '.openclaw.feed_url' "$SKILL_PATH/skill.json" >/dev/null 2>&1; then - echo "Updating openclaw.feed_url to use tag $TAG..." - if ! jq --arg tag "$TAG" '.openclaw.feed_url = (.openclaw.feed_url | gsub("/[^/]+-v[0-9.]+(-[a-zA-Z0-9]+)?/"; "/\($tag)/"))' "$SKILL_PATH/skill.json" > "$TEMP_DIR/skill.json"; then - echo "Error: Failed to update feed_url in skill.json" +if jq -e '.openclaw.feed_url' "$TEMP_DIR/skill.json" >/dev/null 2>&1; then + if ! jq --arg tag "$TAG" \ + '.openclaw.feed_url = (.openclaw.feed_url | gsub("/[^/]+-v[0-9.]+(-[a-zA-Z0-9.]+)?/"; "/\($tag)/"))' \ + "$TEMP_DIR/skill.json" > "$TEMP_DIR/skill.json.next"; then + echo "Error: failed to update openclaw.feed_url." >&2 exit 1 fi - mv "$TEMP_DIR/skill.json" "$SKILL_PATH/skill.json" + mv "$TEMP_DIR/skill.json.next" "$TEMP_DIR/skill.json" fi +queue_prepared_file "$TEMP_DIR/skill.json" "$SKILL_PATH/skill.json" -# Update version in SKILL.md frontmatter and ALL hardcoded version URLs (if file exists) -if [ -f "$SKILL_PATH/SKILL.md" ]; then - echo "Updating $SKILL_PATH/SKILL.md frontmatter version to $VERSION..." - - # Verify version line exists before sed - if ! grep -qE "^version: " "$SKILL_PATH/SKILL.md"; then - echo "Error: SKILL.md missing 'version:' line in frontmatter" >&2 - echo " Expected format: 'version: X.Y.Z' at start of line" >&2 - exit 1 - fi - - # Apply sed and verify substitution occurred - sed "s/^version: .*/version: $VERSION/" "$SKILL_PATH/SKILL.md" > "$TEMP_DIR/SKILL.md" - - if ! grep -qF "version: $VERSION" "$TEMP_DIR/SKILL.md"; then - echo "Error: Failed to update version in SKILL.md frontmatter" >&2 - echo " Target version: $VERSION" >&2 - exit 1 - fi - - echo " ✓ Version updated to $VERSION" +if ! grep -qE '^version: ' "$SKILL_PATH/SKILL.md"; then + echo "Error: SKILL.md is missing a frontmatter version." >&2 + exit 1 +fi +sed "s/^version: .*/version: $VERSION/" \ + "$SKILL_PATH/SKILL.md" > "$TEMP_DIR/SKILL.md" +if ! grep -qF "version: $VERSION" "$TEMP_DIR/SKILL.md"; then + echo "Error: failed to update SKILL.md frontmatter version." >&2 + exit 1 +fi - echo "Updating release verification VERSION assignments in SKILL.md..." - VERSION_ASSIGNMENT_PATTERN='^VERSION="[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?"$' - if grep -qE "$VERSION_ASSIGNMENT_PATTERN" "$TEMP_DIR/SKILL.md"; then - sed -E "s|$VERSION_ASSIGNMENT_PATTERN|VERSION=\"$VERSION\"|g" "$TEMP_DIR/SKILL.md" > "$TEMP_DIR/SKILL.md.tmp" +VERSION_ASSIGNMENT_PATTERN='^VERSION="[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?"$' +if grep -qE "$VERSION_ASSIGNMENT_PATTERN" "$TEMP_DIR/SKILL.md"; then + sed -E "s|$VERSION_ASSIGNMENT_PATTERN|VERSION=\"$VERSION\"|g" \ + "$TEMP_DIR/SKILL.md" > "$TEMP_DIR/SKILL.md.next" + mv "$TEMP_DIR/SKILL.md.next" "$TEMP_DIR/SKILL.md" +fi - if ! grep -qF "VERSION=\"$VERSION\"" "$TEMP_DIR/SKILL.md.tmp"; then - echo "Warning: VERSION assignment found but substitution may have failed" >&2 - else - VERSION_ASSIGNMENT_COUNT=$(grep -cF "VERSION=\"$VERSION\"" "$TEMP_DIR/SKILL.md.tmp") - echo " ✓ Updated $VERSION_ASSIGNMENT_COUNT VERSION assignment(s)" - fi +DOWNLOAD_PATTERN="/download/${SKILL_NAME}-v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?/" +if grep -qE "$DOWNLOAD_PATTERN" "$TEMP_DIR/SKILL.md"; then + sed -E "s|$DOWNLOAD_PATTERN|/download/${TAG}/|g" \ + "$TEMP_DIR/SKILL.md" > "$TEMP_DIR/SKILL.md.next" + mv "$TEMP_DIR/SKILL.md.next" "$TEMP_DIR/SKILL.md" +fi +queue_prepared_file "$TEMP_DIR/SKILL.md" "$SKILL_PATH/SKILL.md" - mv "$TEMP_DIR/SKILL.md.tmp" "$TEMP_DIR/SKILL.md" - else - echo " ℹ No hardcoded release verification VERSION assignments found" +for markdown_file in "$SKILL_PATH"/*.md; do + if [ ! -f "$markdown_file" ] || [ "$markdown_file" = "$SKILL_PATH/SKILL.md" ]; then + continue fi - - echo "Updating hardcoded version URLs in SKILL.md to use tag $TAG..." - # Replace all hardcoded version URLs: download/SKILLNAME-vX.Y.Z(-prerelease)?/ -> download/TAG/ - # This handles patterns like: download/clawsec-feed-v1.0.0/ or download/prompt-agent-v1.0.0-beta1/ - PATTERN="/download/${SKILL_NAME}-v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?/" - - # Check if pattern exists (warn if not, don't fail - some skills may not self-reference) - if grep -qE "$PATTERN" "$TEMP_DIR/SKILL.md"; then - sed -E "s|$PATTERN|/download/${TAG}/|g" "$TEMP_DIR/SKILL.md" > "$TEMP_DIR/SKILL.md.tmp" - - # Verify substitution occurred - if ! grep -qF "/download/${TAG}/" "$TEMP_DIR/SKILL.md.tmp"; then - echo "Warning: URL pattern found but substitution may have failed" >&2 - else - URL_COUNT=$(grep -cF "/download/${TAG}/" "$TEMP_DIR/SKILL.md.tmp") - echo " ✓ Updated $URL_COUNT hardcoded URL(s)" - fi - - mv "$TEMP_DIR/SKILL.md.tmp" "$TEMP_DIR/SKILL.md" - else - echo " ℹ No hardcoded version URLs found (OK if skill doesn't self-reference)" + if ! grep -qE "$DOWNLOAD_PATTERN" "$markdown_file"; then + continue fi + filename="$(basename "$markdown_file")" + sed -E "s|$DOWNLOAD_PATTERN|/download/${TAG}/|g" \ + "$markdown_file" > "$TEMP_DIR/$filename" + queue_prepared_file "$TEMP_DIR/$filename" "$markdown_file" +done - mv "$TEMP_DIR/SKILL.md" "$SKILL_PATH/SKILL.md" - FILES_TO_STAGE+=("$SKILL_PATH/SKILL.md") -fi - -# Update hardcoded version URLs in other markdown files (heartbeat.md, reporting.md, etc.) -for md_file in "$SKILL_PATH"/*.md; do - if [ -f "$md_file" ] && [ "$md_file" != "$SKILL_PATH/SKILL.md" ]; then - filename=$(basename "$md_file") - echo "Updating hardcoded version URLs in $filename..." - - PATTERN="/download/${SKILL_NAME}-v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?/" - - # Check if pattern exists - if grep -qE "$PATTERN" "$md_file"; then - sed -E "s|$PATTERN|/download/${TAG}/|g" "$md_file" > "$TEMP_DIR/$filename" - - # Verify substitution occurred - if ! grep -qF "/download/${TAG}/" "$TEMP_DIR/$filename"; then - echo " Warning: URL pattern found but substitution may have failed in $filename" >&2 - else - URL_COUNT=$(grep -cF "/download/${TAG}/" "$TEMP_DIR/$filename") - echo " ✓ Updated $URL_COUNT URL(s) in $filename" - fi - - mv "$TEMP_DIR/$filename" "$md_file" - FILES_TO_STAGE+=("$md_file") - else - echo " ℹ No hardcoded version URLs found in $filename (skipping)" - fi - fi +mkdir "$TEMP_DIR/backups" +for index in "${!TARGET_FILES[@]}"; do + backup_file="$TEMP_DIR/backups/$index" + cp -p "${TARGET_FILES[$index]}" "$backup_file" + BACKUP_FILES+=("$backup_file") done -# Show what changed -echo "" -echo "Changes to $SKILL_PATH/:" -git diff "$SKILL_PATH/" || true -echo "" +# No repository file is replaced until every transformation above has passed. +# If staging or commit fails, the EXIT trap restores the exact clean inputs and +# stages those originals to clear this helper's index changes without reset. +APPLY_STARTED=true +for index in "${!TARGET_FILES[@]}"; do + mv "${PREPARED_FILES[$index]}" "${TARGET_FILES[$index]}" +done -# Stage all changed files atomically -echo "Staging changes..." -for file in "${FILES_TO_STAGE[@]}"; do - git add "$file" +for file in "${TARGET_FILES[@]}"; do + git add -- "$file" done -# Verify staged changes before committing -MADE_COMMIT=false if git diff --cached --quiet; then - echo "Note: Version already at $VERSION — no changes to commit" - COMMIT_SHA=$(git rev-parse HEAD) + echo "No version-preparation changes were needed; $SKILL_NAME is already at $VERSION." else - # Commit the version bump - echo "Committing changes..." - if ! git commit -m "chore($SKILL_NAME): bump version to $VERSION"; then - echo "Error: Failed to commit changes" - exit 1 - fi - COMMIT_SHA=$(git rev-parse HEAD) - MADE_COMMIT=true + git commit -m "chore($SKILL_NAME): bump version to $VERSION" fi -# Save commit SHA for recovery -echo "Committed: $COMMIT_SHA" +COMMIT_SHA="$(git rev-parse HEAD)" +echo "Prepared commit: $COMMIT_SHA" +PREPARATION_COMPLETED=true -# Create tag only on release branches (or if forced) -if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then - # Check if tag already exists (only matters when we're creating one) - if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Error: Tag $TAG already exists" - if [[ "$MADE_COMMIT" == "true" ]]; then - echo "Rolling back version-bump commit..." - git reset --hard HEAD~1 - fi - exit 1 - fi - - echo "Creating tag: $TAG" - if ! git tag -a "$TAG" -m "$SKILL_NAME version $VERSION"; then - echo "Error: Failed to create tag $TAG" >&2 - echo "" >&2 - echo "The commit has been created but NOT tagged:" >&2 - echo " Commit: $COMMIT_SHA" >&2 - echo "" >&2 - echo "Recovery options:" >&2 - echo " 1. Fix the issue and tag manually:" >&2 - echo " git tag -a '$TAG' -m '$SKILL_NAME version $VERSION' $COMMIT_SHA" >&2 - echo "" >&2 - echo " 2. Investigate why tagging failed:" >&2 - echo " - Check if tag exists: git tag -l '$TAG'" >&2 - echo " - Check permissions: ls -ld .git/refs/tags" >&2 - echo "" >&2 - echo " 3. To rollback the commit (if desired):" >&2 - echo " git reset --hard HEAD~1" >&2 - echo "" >&2 - echo "The commit has NOT been pushed. Fix the issue before pushing." >&2 - exit 1 - fi - - # Extract changelog entry for this version and create GitHub release - RELEASE_NOTES="" - GH_RELEASE_CREATED=false - if [ -f "$SKILL_PATH/CHANGELOG.md" ]; then - echo "Extracting changelog entry for version $VERSION..." - - # Extract the changelog section for this version - # Pattern: ## [VERSION] - DATE ... until next ## [ or end of file - RELEASE_NOTES=$(awk -v version="$VERSION" ' - BEGIN { in_section = 0; found = 0 } - $0 ~ ("^## \\[" version "\\]") { in_section = 1; found = 1; next } - in_section && /^## \[/ && found { exit } - in_section { print } - ' "$SKILL_PATH/CHANGELOG.md" | sed '/^$/d' | sed '1{/^$/d;}') - - if [ -n "$RELEASE_NOTES" ]; then - echo "Found changelog entry with $(echo "$RELEASE_NOTES" | wc -l) lines" - - # Create GitHub release with changelog notes - echo "Creating GitHub release with changelog notes..." - if command -v gh >/dev/null 2>&1; then - if ! echo "$RELEASE_NOTES" | gh release create "$TAG" \ - --title "$SKILL_NAME v$VERSION" \ - --notes-file -; then - echo "Warning: Failed to create GitHub release, but tag was created successfully" >&2 - echo "You can manually create the release at: https://github.com/$(git remote get-url origin | sed 's/.*github.com[:/]\([^.]*\).*/\1/')/releases/new" >&2 - else - echo "✓ GitHub release created with changelog notes" - GH_RELEASE_CREATED=true - fi - else - echo "Warning: GitHub CLI (gh) not found. Skipping automatic release creation." >&2 - echo "Install GitHub CLI and run manually:" >&2 - echo " gh release create '$TAG' --title '$SKILL_NAME v$VERSION' --notes-file <(echo \"$RELEASE_NOTES\")" >&2 - fi - else - echo "Warning: No changelog entry found for version $VERSION" >&2 - fi - else - echo "No CHANGELOG.md found in $SKILL_PATH - skipping release notes" - fi - - echo "" - echo "Done! To release, push the tag:" - if [[ "$MADE_COMMIT" == "true" ]]; then - echo " git push origin $CURRENT_BRANCH" - fi - echo " git push origin $TAG" - echo "" - echo "Or to undo:" - if [[ "$MADE_COMMIT" == "true" ]]; then - echo " git reset --hard HEAD~1 && git tag -d $TAG" - else - echo " git tag -d $TAG" - fi - if [[ "$GH_RELEASE_CREATED" == "true" ]]; then - echo "" - echo "Note: GitHub release was created automatically with changelog notes." - fi -else - # Feature branch: skip tagging, instruct user on next steps - if [[ "$INSTALLABLE" == "false" ]]; then - echo "" - echo "Done! Non-installable package version updated and committed for review." - echo "" - echo "Next step: push this branch so CI can build signed denial evidence." - echo " git push origin $CURRENT_BRANCH" - echo "" - echo "Do not create or push a public tag, GitHub Release, or skill-store publication for this package." - exit 0 - fi - - echo "" - echo "Done! Version updated and committed (tag deferred)." - echo "" - echo "Next steps:" - echo " 1. Push your branch for CI validation:" - echo " git push origin $CURRENT_BRANCH" - echo "" - echo " 2. After CI passes and PR is merged to main, create the tag and release:" - echo " git checkout main && git pull" - echo " git tag -a '$TAG' $COMMIT_SHA -m '$SKILL_NAME version $VERSION'" - echo " git push origin $TAG" - if [ -f "$SKILL_PATH/CHANGELOG.md" ]; then - echo " # Create GitHub release with changelog (requires GitHub CLI):" - echo " gh release create '$TAG' --title '$SKILL_NAME v$VERSION' --generate-notes" - fi - echo "" - echo "Or to undo the version bump:" - echo " git reset --hard HEAD~1" +if [ "$INSTALLABLE" = "false" ]; then + echo "This package is non-installable. Push the review branch for signed denial evidence." + echo "Do not request a public tag, GitHub Release, store publication, or catalog activation." + exit 0 fi + +echo "Next steps:" +echo " 1. Push $CURRENT_BRANCH and complete its review and CI." +echo " 2. Merge through the protected-main process." +echo " 3. Run 'Create Skill Release Tag' with package $SKILL_NAME, version $VERSION," +echo " and the exact resulting protected-main commit." diff --git a/scripts/test-advisory-mirror-propagation.mjs b/scripts/test-advisory-mirror-propagation.mjs index 95c712ef..410688da 100644 --- a/scripts/test-advisory-mirror-propagation.mjs +++ b/scripts/test-advisory-mirror-propagation.mjs @@ -48,7 +48,10 @@ const feedUtils = await readFile(new URL("scripts/feed-utils.sh", root), "utf8") assert.ok(feedUtils.includes("skills/clawsec-suite/advisories/feed.json")); assert.ok(feedUtils.includes('for target in "$SKILL_FEED_PATH" "$SUITE_FEED_PATH" "$PUBLIC_FEED_PATH"')); -const releaseWorkflow = await readFile(new URL(".github/workflows/skill-release.yml", root), "utf8"); +const releaseWorkflow = await readFile( + new URL(".github/workflows/controlled-skill-release.yml", root), + "utf8", +); const installDocValidator = await readFile(new URL("scripts/ci/validate_skill_install_docs.mjs", root), "utf8"); for (const mirrorPath of [ "skills/clawsec-feed/advisories/feed.json", diff --git a/scripts/test-skill-controlled-tag-creation.mjs b/scripts/test-skill-controlled-tag-creation.mjs new file mode 100644 index 00000000..0c412c2e --- /dev/null +++ b/scripts/test-skill-controlled-tag-creation.mjs @@ -0,0 +1,671 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + CONTROLLED_RELEASE_WORKFLOW, + GitHubApiError, + createControlledTagPlan, + createOrResumeControlledTag, + dispatchControlledRelease, + preflightControlledTag, + validateSourceCheckout, + validateTagRuleset, + verifyControlledReleaseTuple, +} from "./ci/controlled_tag_creation.mjs"; +import { loadLegacyPrereleaseInventory } from "./ci/stable_tag_policy.mjs"; + +const repository = "prompt-security/clawsec"; +const defaultBranch = "main"; +const sourceCommit = "a".repeat(40); +const advancedCommit = "b".repeat(40); +const createdObjectOid = "c".repeat(40); +const racedObjectOid = "d".repeat(40); +const releaseAttemptId = "123456:789012"; +const rulesetId = 24680; +const appId = 13579; +const rulesetUpdatedAt = "2026-07-23T12:34:56Z"; +const { historicalTags } = await loadLegacyPrereleaseInventory(); + +function plan(overrides = {}) { + return createControlledTagPlan({ + repository, + packageName: "clawsec-suite", + version: "0.2.0", + sourceCommit, + releaseAttemptId, + rulesetId, + rulesetUpdatedAt, + appId, + historicalTags, + ...overrides, + }); +} + +function ruleset(overrides = {}) { + return { + id: rulesetId, + source_type: "Repository", + source: repository, + updated_at: rulesetUpdatedAt, + target: "tag", + enforcement: "active", + conditions: { + ref_name: { + include: ["refs/tags/*-v*"], + exclude: [], + }, + }, + rules: [ + { type: "creation" }, + { type: "update" }, + { type: "deletion" }, + { type: "non_fast_forward" }, + ], + bypass_actors: [ + { actor_type: "Integration", actor_id: appId, bypass_mode: "always" }, + ], + ...overrides, + }; +} + +function refResponse(releasePlan, tagObjectOid = createdObjectOid) { + return { + ref: `refs/tags/${releasePlan.tag}`, + object: { type: "tag", sha: tagObjectOid }, + }; +} + +function tagResponse(releasePlan, tagObjectOid = createdObjectOid) { + return { + sha: tagObjectOid, + tag: releasePlan.tag, + message: releasePlan.tag_message, + object: { type: "commit", sha: releasePlan.protected_main_commit }, + }; +} + +class FakeClient { + constructor(role, handler) { + this.role = role; + this.handler = handler; + this.calls = []; + } + + async request(apiPath, options = {}) { + const call = { + role: this.role, + path: apiPath, + method: options.method ?? "GET", + body: options.body, + expected: options.expected ?? [200], + }; + this.calls.push(call); + return this.handler(call, this.calls.length - 1); + } +} + +function writeCalls(client) { + return client.calls.filter(({ method }) => method !== "GET"); +} + +function missingTag() { + throw new GitHubApiError("missing", { status: 404 }); +} + +const validPlan = plan(); +assert.equal(validPlan.tag, "clawsec-suite-v0.2.0"); +assert.equal(validPlan.repository, repository); +assert.match(validPlan.tag_message_sha256, /^[0-9a-f]{64}$/); +assert.deepEqual(JSON.parse(validPlan.tag_message), { + schema: "clawsec.controlled-tag/v1", + repository, + package_name: "clawsec-suite", + version: "0.2.0", + tag: "clawsec-suite-v0.2.0", + protected_main_commit: sourceCommit, + release_attempt_id: releaseAttemptId, + tag_ruleset_id: rulesetId, + tag_ruleset_updated_at: rulesetUpdatedAt, + release_app_id: appId, +}); + +for (const version of [ + "0.2.0-beta.1", + "0.2.0-rc.1", + "0.2.0-beta1", + "0.2.0+build.1", + "0.2.0\noutput=true", + "01.2.3", +]) { + assert.throws( + () => plan({ version }), + /stable tag policy denied/, + `public controller must reject ${JSON.stringify(version)}`, + ); +} +for (const [field, value] of [ + ["packageName", "../clawsec-suite"], + ["packageName", "ClawSec-suite"], + ["sourceCommit", "A".repeat(40)], + ["sourceCommit", "a".repeat(39)], + ["releaseAttemptId", "123456:0"], + ["releaseAttemptId", "not-an-attempt"], + ["rulesetUpdatedAt", "not-a-timestamp"], + ["repository", "prompt-security/clawsec/extra"], +]) { + assert.throws(() => plan({ [field]: value })); +} + +const rulesetExpectation = { + ruleset_id: rulesetId, + ruleset_updated_at: rulesetUpdatedAt, + app_id: appId, + active: true, + bypass_actor_visibility: "api_verified", +}; +assert.deepEqual( + validateTagRuleset(ruleset(), { repository, rulesetId, rulesetUpdatedAt, appId }), + rulesetExpectation, +); +const redactedRuleset = ruleset(); +delete redactedRuleset.bypass_actors; +assert.deepEqual( + validateTagRuleset(redactedRuleset, { repository, rulesetId, rulesetUpdatedAt, appId }), + { ...rulesetExpectation, bypass_actor_visibility: "redacted_operator_proof_required" }, +); +for (const unsafeRuleset of [ + ruleset({ enforcement: "evaluate" }), + ruleset({ target: "branch" }), + ruleset({ source: "prompt-security/other" }), + ruleset({ updated_at: "2026-07-23T12:34:57Z" }), + ruleset({ conditions: { ref_name: { include: ["refs/tags/special"], exclude: [] } } }), + ruleset({ conditions: { ref_name: { include: ["~ALL"], exclude: ["refs/tags/a-v1.0.0"] } } }), + ruleset({ rules: [{ type: "creation" }, { type: "update" }, { type: "deletion" }] }), + ruleset({ bypass_actors: [] }), + ruleset({ bypass_actors: [ + { actor_type: "Integration", actor_id: appId, bypass_mode: "always" }, + { actor_type: "RepositoryRole", actor_id: 5, bypass_mode: "always" }, + ] }), + ruleset({ bypass_actors: [ + { actor_type: "Integration", actor_id: appId + 1, bypass_mode: "always" }, + ] }), +]) { + assert.throws(() => validateTagRuleset( + unsafeRuleset, + { repository, rulesetId, rulesetUpdatedAt, appId }, + )); +} + +const sourceFixtureRoots = []; +async function makeSourceFixture(dirtyMode = "clean") { + const root = await mkdtemp(path.join(tmpdir(), "clawsec-controlled-source-")); + sourceFixtureRoots.push(root); + const skillDir = path.join(root, "skills", "clawsec-suite"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + path.join(skillDir, "skill.json"), + `${JSON.stringify({ name: "clawsec-suite", version: "0.2.0", installable: true }, null, 2)}\n`, + ); + await writeFile( + path.join(skillDir, "SKILL.md"), + "---\nname: clawsec-suite\nversion: 0.2.0\n---\n\n# Fixture\n", + ); + execFileSync("git", ["init", "--initial-branch=main"], { cwd: root }); + execFileSync("git", ["config", "user.name", "ClawSec Test"], { cwd: root }); + execFileSync("git", ["config", "user.email", "clawsec-test@example.invalid"], { cwd: root }); + execFileSync("git", ["add", "."], { cwd: root }); + execFileSync("git", ["commit", "-m", "fixture"], { cwd: root }); + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); + const fixturePlan = plan({ sourceCommit: head }); + if (dirtyMode === "tracked") { + await writeFile(path.join(skillDir, "SKILL.md"), "changed\n"); + } else if (dirtyMode === "staged") { + await writeFile(path.join(root, "staged.txt"), "staged\n"); + execFileSync("git", ["add", "staged.txt"], { cwd: root }); + } else if (dirtyMode === "untracked") { + await writeFile(path.join(root, "untracked.txt"), "untracked\n"); + } + return { root, fixturePlan }; +} + +try { + const cleanFixture = await makeSourceFixture(); + const validated = await validateSourceCheckout({ + repoRoot: cleanFixture.root, + plan: cleanFixture.fixturePlan, + }); + assert.equal(validated.source_checkout_verified, true); + + for (const dirtyMode of ["tracked", "staged", "untracked"]) { + const fixture = await makeSourceFixture(dirtyMode); + await assert.rejects( + () => validateSourceCheckout({ repoRoot: fixture.root, plan: fixture.fixturePlan }), + /source checkout is dirty/, + ); + } + + const metadataMismatch = await makeSourceFixture(); + const metadataPath = path.join(metadataMismatch.root, "skills", "clawsec-suite", "skill.json"); + await writeFile( + metadataPath, + `${JSON.stringify({ name: "other", version: "0.2.0", installable: true })}\n`, + ); + execFileSync("git", ["add", "."], { cwd: metadataMismatch.root }); + execFileSync("git", ["commit", "-m", "mismatch"], { cwd: metadataMismatch.root }); + const mismatchHead = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: metadataMismatch.root, + encoding: "utf8", + }).trim(); + await assert.rejects( + () => validateSourceCheckout({ + repoRoot: metadataMismatch.root, + plan: plan({ sourceCommit: mismatchHead }), + }), + /skill\.json name must exactly match/, + ); + + const nonInstallable = await makeSourceFixture(); + const nonInstallablePath = path.join(nonInstallable.root, "skills", "clawsec-suite", "skill.json"); + await writeFile( + nonInstallablePath, + `${JSON.stringify({ name: "clawsec-suite", version: "0.2.0", installable: false })}\n`, + ); + execFileSync("git", ["add", "."], { cwd: nonInstallable.root }); + execFileSync("git", ["commit", "-m", "non-installable"], { cwd: nonInstallable.root }); + const nonInstallableHead = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: nonInstallable.root, + encoding: "utf8", + }).trim(); + await assert.rejects( + () => validateSourceCheckout({ + repoRoot: nonInstallable.root, + plan: plan({ sourceCommit: nonInstallableHead }), + }), + /Publication denied/, + ); +} finally { + await Promise.all(sourceFixtureRoots.map((root) => rm(root, { recursive: true, force: true }))); +} + +function creationClient({ mainHeads = [sourceCommit, sourceCommit], unsafeRuleset, refRace = false } = {}) { + let mainIndex = 0; + let readback = false; + return new FakeClient("release-app", ({ path: apiPath, method }) => { + if (apiPath.endsWith(`/rulesets/${rulesetId}`) && method === "GET") { + return { status: 200, data: unsafeRuleset ?? ruleset() }; + } + if (apiPath.includes("/git/ref/tags/") && method === "GET") { + if (!readback) { + readback = true; + return missingTag(); + } + const objectOid = refRace ? racedObjectOid : createdObjectOid; + return { status: 200, data: refResponse(validPlan, objectOid) }; + } + if (apiPath.endsWith("/git/ref/heads/main") && method === "GET") { + const sha = mainHeads[Math.min(mainIndex, mainHeads.length - 1)]; + mainIndex += 1; + return { status: 200, data: { object: { sha } } }; + } + if (apiPath.endsWith("/git/tags") && method === "POST") { + return { status: 201, data: tagResponse(validPlan, createdObjectOid) }; + } + if (apiPath.endsWith("/git/refs") && method === "POST") { + if (refRace) { + throw new GitHubApiError("race", { status: 422 }); + } + return { status: 201, data: { ref: `refs/tags/${validPlan.tag}` } }; + } + if (apiPath.endsWith(`/git/tags/${refRace ? racedObjectOid : createdObjectOid}`)) { + return { + status: 200, + data: tagResponse(validPlan, refRace ? racedObjectOid : createdObjectOid), + }; + } + throw new Error(`Unexpected fake API call: ${method} ${apiPath}`); + }); +} + +const createdClient = creationClient(); +const created = await createOrResumeControlledTag({ + client: createdClient, + repository, + defaultBranch, + rulesetId, + appId, + plan: validPlan, +}); +assert.equal(created.operation, "created"); +assert.equal(created.tag_object_oid, createdObjectOid); +assert.deepEqual(writeCalls(createdClient).map(({ method, path: apiPath }) => [method, apiPath]), [ + ["POST", `/repos/prompt-security/clawsec/git/tags`], + ["POST", `/repos/prompt-security/clawsec/git/refs`], +]); +assert.equal(writeCalls(createdClient)[0].role, "release-app"); +assert.deepEqual(writeCalls(createdClient)[0].body, { + tag: validPlan.tag, + message: validPlan.tag_message, + object: sourceCommit, + type: "commit", +}); +assert.deepEqual(writeCalls(createdClient)[1].body, { + ref: `refs/tags/${validPlan.tag}`, + sha: createdObjectOid, +}); + +const exactRetryClient = new FakeClient("release-app", ({ path: apiPath }) => { + if (apiPath.endsWith(`/rulesets/${rulesetId}`)) return { status: 200, data: ruleset() }; + if (apiPath.includes("/git/ref/tags/")) return { status: 200, data: refResponse(validPlan) }; + if (apiPath.endsWith(`/git/tags/${createdObjectOid}`)) { + return { status: 200, data: tagResponse(validPlan) }; + } + throw new Error(`Unexpected retry call: ${apiPath}`); +}); +const resumed = await createOrResumeControlledTag({ + client: exactRetryClient, + repository, + defaultBranch, + rulesetId, + appId, + plan: validPlan, +}); +assert.equal(resumed.operation, "resumed"); +assert.equal(writeCalls(exactRetryClient).length, 0); +assert.equal( + exactRetryClient.calls.some(({ path: apiPath }) => apiPath.endsWith("/git/ref/heads/main")), + false, + "an exact same-attempt retry must remain resumable after protected main advances", +); + +const wrongAttemptPlan = plan({ releaseAttemptId: "123456:789013" }); +const wrongAttemptClient = new FakeClient("release-app", ({ path: apiPath }) => { + if (apiPath.endsWith(`/rulesets/${rulesetId}`)) return { status: 200, data: ruleset() }; + if (apiPath.includes("/git/ref/tags/")) return { status: 200, data: refResponse(validPlan) }; + if (apiPath.endsWith(`/git/tags/${createdObjectOid}`)) { + return { status: 200, data: tagResponse(validPlan) }; + } + throw new Error(`Unexpected wrong-attempt call: ${apiPath}`); +}); +await assert.rejects( + () => createOrResumeControlledTag({ + client: wrongAttemptClient, + repository, + defaultBranch, + rulesetId, + appId, + plan: wrongAttemptPlan, + }), + /tag message does not match/, +); +assert.equal(writeCalls(wrongAttemptClient).length, 0); + +for (const [label, client, expectedError] of [ + ["inactive ruleset", creationClient({ unsafeRuleset: ruleset({ enforcement: "evaluate" }) }), /active enforcement/], + ["initial main mismatch", creationClient({ mainHeads: [advancedCommit] }), /protected main changed/], + ["pre-write main movement", creationClient({ mainHeads: [sourceCommit, advancedCommit] }), /protected main changed/], +]) { + await assert.rejects( + () => createOrResumeControlledTag({ + client, + repository, + defaultBranch, + rulesetId, + appId, + plan: validPlan, + }), + expectedError, + label, + ); + assert.equal(writeCalls(client).length, 0, `${label} must produce no write`); +} + +const raceClient = creationClient({ refRace: true }); +const raced = await createOrResumeControlledTag({ + client: raceClient, + repository, + defaultBranch, + rulesetId, + appId, + plan: validPlan, +}); +assert.equal(raced.operation, "resumed"); +assert.equal(raced.tag_object_oid, racedObjectOid); +assert.equal(writeCalls(raceClient).length, 2); + +const lightweightClient = new FakeClient("release-app", ({ path: apiPath }) => { + if (apiPath.endsWith(`/rulesets/${rulesetId}`)) return { status: 200, data: ruleset() }; + if (apiPath.includes("/git/ref/tags/")) { + return { status: 200, data: { + ref: `refs/tags/${validPlan.tag}`, + object: { type: "commit", sha: sourceCommit }, + } }; + } + throw new Error(`Unexpected lightweight call: ${apiPath}`); +}); +await assert.rejects( + () => createOrResumeControlledTag({ + client: lightweightClient, + repository, + defaultBranch, + rulesetId, + appId, + plan: validPlan, + }), + /annotated tag object/, +); +assert.equal(writeCalls(lightweightClient).length, 0); + +const preflightRetryClient = new FakeClient("github-token", ({ path: apiPath }) => { + if (apiPath.includes("/git/ref/tags/")) return { status: 200, data: refResponse(validPlan) }; + if (apiPath.endsWith(`/git/tags/${createdObjectOid}`)) { + return { status: 200, data: tagResponse(validPlan) }; + } + if (apiPath.endsWith("/git/ref/heads/main")) { + return { status: 200, data: { object: { sha: advancedCommit } } }; + } + if (apiPath.includes(`/compare/${sourceCommit}...${advancedCommit}`)) { + return { + status: 200, + data: { status: "ahead", merge_base_commit: { sha: sourceCommit } }, + }; + } + throw new Error(`Unexpected preflight call: ${apiPath}`); +}); +const retryPreflight = await preflightControlledTag({ + client: preflightRetryClient, + repository, + defaultBranch, + plan: validPlan, +}); +assert.equal(retryPreflight.preflight, "exact_retry"); +assert.equal(writeCalls(preflightRetryClient).length, 0); + +function dispatchClient({ advancedMain = false, wrongObject = false } = {}) { + return new FakeClient("github-token", ({ path: apiPath, method, body }) => { + if (apiPath.includes("/git/ref/tags/") && method === "GET") { + return { status: 200, data: refResponse(validPlan, wrongObject ? racedObjectOid : createdObjectOid) }; + } + if (apiPath.endsWith(`/git/tags/${wrongObject ? racedObjectOid : createdObjectOid}`)) { + return { + status: 200, + data: tagResponse(validPlan, wrongObject ? racedObjectOid : createdObjectOid), + }; + } + if (apiPath.endsWith("/git/ref/heads/main")) { + return { status: 200, data: { object: { sha: advancedMain ? advancedCommit : sourceCommit } } }; + } + if (apiPath.includes(`/compare/${sourceCommit}...${advancedCommit}`)) { + return { + status: 200, + data: { status: "ahead", merge_base_commit: { sha: sourceCommit } }, + }; + } + if ( + apiPath.endsWith(`/actions/workflows/${CONTROLLED_RELEASE_WORKFLOW}/dispatches`) + && method === "POST" + ) { + assert.deepEqual(body, { + ref: "main", + inputs: { + operation: "controlled_release", + tag: validPlan.tag, + tag_object_oid: createdObjectOid, + peeled_commit_oid: sourceCommit, + protected_main_commit: sourceCommit, + release_attempt_id: releaseAttemptId, + tag_ruleset_id: String(rulesetId), + tag_ruleset_updated_at: rulesetUpdatedAt, + release_app_id: String(appId), + }, + }); + return { + status: 200, + data: { + workflow_run_id: 987654, + run_url: "https://api.github.test/actions/runs/987654", + html_url: "https://github.test/actions/runs/987654", + }, + }; + } + throw new Error(`Unexpected dispatch call: ${method} ${apiPath}`); + }); +} + +for (const advancedMain of [false, true]) { + const client = dispatchClient({ advancedMain }); + const dispatched = await dispatchControlledRelease({ + client, + repository, + defaultBranch, + plan: validPlan, + tagObjectOid: createdObjectOid, + peeledCommitOid: sourceCommit, + }); + assert.equal(dispatched.downstream_workflow_run_id, 987654); + const writes = writeCalls(client); + assert.equal(writes.length, 1); + assert.equal(writes[0].role, "github-token"); + assert.match(writes[0].path, /controlled-skill-release\.yml\/dispatches$/); +} + +const wrongObjectClient = dispatchClient({ wrongObject: true }); +await assert.rejects( + () => verifyControlledReleaseTuple({ + client: wrongObjectClient, + repository, + plan: validPlan, + tagObjectOid: createdObjectOid, + peeledCommitOid: sourceCommit, + }), + /tag-object ID does not match/, +); +assert.equal(writeCalls(wrongObjectClient).length, 0); + +const controllerWorkflow = await readFile( + new URL("../.github/workflows/create-skill-release-tag.yml", import.meta.url), + "utf8", +); +const releaseWorkflow = await readFile( + new URL("../.github/workflows/controlled-skill-release.yml", import.meta.url), + "utf8", +); +const releaseScript = await readFile(new URL("./release-skill.sh", import.meta.url), "utf8"); +assert.equal( + existsSync(new URL("../.github/workflows/skill-release.yml", import.meta.url)), + false, + "historically dispatchable release workflow path must be removed", +); + +assert.match(controllerWorkflow, /^name: Create Skill Release Tag/m); +assert.match(controllerWorkflow, /environment: clawsec-stable-release/); +assert.match(controllerWorkflow, /CLAWSEC_CONTROLLED_RELEASE_ENABLED/); +assert.match(controllerWorkflow, /CLAWSEC_RELEASE_TAG_RULESET_UPDATED_AT/); +assert.match(controllerWorkflow, /refs\/heads\/\$DEFAULT_BRANCH/); +assert.match(controllerWorkflow, /path: policy[\s\S]*path: source/); +assert.match(controllerWorkflow, /actions\/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1/); +const writeJob = controllerWorkflow.slice( + controllerWorkflow.indexOf(" write-annotated-tag:"), + controllerWorkflow.indexOf(" dispatch-release:"), +); +const postApprovalGate = writeJob.indexOf( + "- name: Recheck enabled controller after environment approval", +); +const mintAppToken = writeJob.indexOf("- name: Mint repository-scoped release App token"); +assert.ok(postApprovalGate >= 0, "write job must recheck the release kill switch"); +assert.ok( + postApprovalGate < mintAppToken, + "environment-gated kill-switch recheck must run before minting the write token", +); +assert.match( + writeJob.slice(postApprovalGate, mintAppToken), + /RELEASE_ENABLED: \$\{\{ vars\.CLAWSEC_CONTROLLED_RELEASE_ENABLED \}\}[\s\S]*\[\[ "\$RELEASE_ENABLED" != "true" \]\]/, +); +const appTokenStep = controllerWorkflow.slice( + controllerWorkflow.indexOf("- name: Mint repository-scoped release App token"), + controllerWorkflow.indexOf("- name: Preflight ruleset"), +); +assert.match(appTokenStep, /permission-contents: write/); +assert.doesNotMatch( + appTokenStep, + /permission-actions|permission-workflows|permission-administration|github\.token/, +); +const dispatchJob = controllerWorkflow.slice(controllerWorkflow.indexOf(" dispatch-release:")); +assert.match(dispatchJob, /actions: write[\s\S]*contents: read/); +assert.match(dispatchJob, /GITHUB_TOKEN: \$\{\{ github\.token \}\}/); +assert.doesNotMatch(dispatchJob, /release-app-token|CONTROLLED_TAG_TOKEN|APP_PRIVATE_KEY/); +assert.match(dispatchJob, /dispatch-release/); + +assert.doesNotMatch(releaseWorkflow, /\n\s+push:\s*\n\s+tags:/); +assert.match(releaseWorkflow, /operation:[\s\S]*republish_clawhub[\s\S]*controlled_release/); +assert.match(releaseWorkflow, /REQUESTED_REF[\s\S]*refs\/heads\/\$DEFAULT_BRANCH/); +assert.match(releaseWorkflow, /controlled_tag_creation\.mjs verify-release/); +assert.match(releaseWorkflow, /inputs\.operation == 'controlled_release'/); +assert.match(releaseWorkflow, /inputs\.operation == 'republish_clawhub'/); +const releaseJob = releaseWorkflow.slice( + releaseWorkflow.indexOf(" release-tag:"), + releaseWorkflow.indexOf(" publish-clawhub:"), +); +assert.ok( + releaseJob.indexOf("Reverify controlled tag provenance") + < releaseJob.indexOf("Sign embedded advisory feed and verify"), + "release provenance must be reverified before any signing or release write", +); +assert.match( + releaseJob, + /ref: \$\{\{ needs\.stable-publication-policy\.outputs\.peeled_commit_oid \}\}/, +); + +for (const forbidden of [ + /--force-tag/, + /\bgit tag\b/, + /\bgh release create\b/, + /git push origin \$TAG/, + /git tag -d/, + /git reset --hard/, +]) { + assert.doesNotMatch(releaseScript, forbidden); +} +assert.match(releaseScript, /version preparation must run on a review branch/); +assert.match(releaseScript, /repository must be globally clean/); +assert.match(releaseScript, /Create Skill Release Tag/); + +const helperSource = await readFile( + new URL("./ci/controlled_tag_creation.mjs", import.meta.url), + "utf8", +); +assert.doesNotMatch(helperSource, /method:\s*["'](?:PATCH|DELETE)["']/); +assert.doesNotMatch(helperSource, /force:\s*true|\/git\/refs\/tags\/.*PATCH/); + +process.stdout.write("Controlled tag creation tests passed.\n"); diff --git a/scripts/test-skill-install-docs.mjs b/scripts/test-skill-install-docs.mjs index 338b153b..817d63d4 100644 --- a/scripts/test-skill-install-docs.mjs +++ b/scripts/test-skill-install-docs.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; const validator = "scripts/ci/validate_skill_install_docs.mjs"; -const workflow = await readFile(".github/workflows/skill-release.yml", "utf8"); +const workflow = await readFile(".github/workflows/controlled-skill-release.yml", "utf8"); const tempRoot = await mkdtemp(path.join(tmpdir(), "clawsec-install-docs-")); const agentTypesPath = path.join(tempRoot, "vercel-types.ts"); diff --git a/scripts/test-skill-release-script-installability.mjs b/scripts/test-skill-release-script-installability.mjs index 556ea6c7..a359362d 100644 --- a/scripts/test-skill-release-script-installability.mjs +++ b/scripts/test-skill-release-script-installability.mjs @@ -22,11 +22,11 @@ const fakeBinDir = path.join(fixtureRoot, "fake-bin"); const ghAttemptPath = path.join(fixtureRoot, "gh-attempted"); const childPath = `${fakeBinDir}:${path.dirname(process.execPath)}:${process.env.PATH ?? ""}`; -function run(command, args) { +function run(command, args, extraEnv = {}) { return spawnSync(command, args, { cwd: fixtureRoot, encoding: "utf8", - env: { ...process.env, PATH: childPath }, + env: { ...process.env, PATH: childPath, ...extraEnv }, }); } @@ -64,16 +64,49 @@ try { installable: false, }, null, 2)}\n`, ), + writeFile( + path.join(skillDir, "SKILL.md"), + "---\nname: retired-skill\nversion: 0.0.1\n---\n\n# Retired skill\n", + ), writeFile( path.join(skillDir, "CHANGELOG.md"), "# Changelog\n\n## [0.0.2] - 2026-07-22\n\n- Test release.\n", ), + mkdir(path.join(fixtureRoot, "skills", "broken-skill"), { recursive: true }), writeFile( path.join(fakeBinDir, "gh"), `#!/bin/sh\nprintf attempted > "${ghAttemptPath}"\nexit 0\n`, ), + writeFile( + path.join(fakeBinDir, "mv"), + "#!/bin/sh\n/bin/mv \"$@\"\n" + + "if [ -n \"${CLAWSEC_TEST_SIGNAL_AFTER_APPLY:-}\" ] && " + + "[ \"${2:-}\" = \"skills/retired-skill/skill.json\" ]; then\n" + + " kill \"-$CLAWSEC_TEST_SIGNAL_AFTER_APPLY\" \"$PPID\"\nfi\n", + ), + ]); + await Promise.all([ + writeFile( + path.join(fixtureRoot, "skills", "broken-skill", "skill.json"), + `${JSON.stringify({ + name: "broken-skill", + version: "0.0.1", + installable: false, + }, null, 2)}\n`, + ), + writeFile( + path.join(fixtureRoot, "skills", "broken-skill", "SKILL.md"), + "---\nname: broken-skill\n---\n\n# Broken skill\n", + ), + writeFile( + path.join(fixtureRoot, "skills", "broken-skill", "CHANGELOG.md"), + "# Changelog\n\n## [0.0.2] - 2026-07-22\n\n- Test release.\n", + ), + ]); + await Promise.all([ + chmod(path.join(fakeBinDir, "gh"), 0o700), + chmod(path.join(fakeBinDir, "mv"), 0o700), ]); - await chmod(path.join(fakeBinDir, "gh"), 0o700); runGit("init", "--initial-branch=main"); runGit("config", "user.name", "ClawSec Test"); @@ -85,14 +118,75 @@ try { const mainHead = runGit("rev-parse", "HEAD"); const fullRelease = run("bash", ["scripts/release-skill.sh", "retired-skill", "0.0.2"]); - assert.equal(fullRelease.status, 1, "main-branch release of a non-installable skill must fail"); - assert.match(fullRelease.stderr, /Publication denied .*"installable": false/); + assert.equal(fullRelease.status, 1, "main-branch version preparation must fail"); + assert.match(fullRelease.stderr, /must run on a review branch, not protected main/); assert.equal(runGit("rev-parse", "HEAD"), mainHead, "denial must happen before commit creation"); assert.equal(runGit("status", "--short"), "", "denial must happen before file mutation or staging"); assert.equal(runGit("tag", "--list"), "", "denial must happen before tag creation"); assert.equal(existsSync(ghAttemptPath), false, "denial must happen before GitHub release creation"); runGit("switch", "-c", "review/non-installable"); + const malformedHead = runGit("rev-parse", "HEAD"); + const malformedPrep = run("bash", ["scripts/release-skill.sh", "broken-skill", "0.0.2"]); + assert.equal(malformedPrep.status, 1, "invalid metadata must fail before applying any file"); + assert.match(malformedPrep.stderr, /SKILL\.md is missing a frontmatter version/); + assert.equal(runGit("rev-parse", "HEAD"), malformedHead); + assert.equal(runGit("status", "--short"), "", "pre-apply failure must preserve a clean tree"); + const brokenSkill = JSON.parse( + await readFile(path.join(fixtureRoot, "skills", "broken-skill", "skill.json"), "utf8"), + ); + assert.equal(brokenSkill.version, "0.0.1", "pre-apply failure must preserve original metadata"); + + for (const [signal, expectedStatus] of [ + ["HUP", 129], + ["INT", 130], + ["TERM", 143], + ]) { + const signalFailureHead = runGit("rev-parse", "HEAD"); + const signalFailure = run( + "bash", + ["scripts/release-skill.sh", "retired-skill", "0.0.2"], + { CLAWSEC_TEST_SIGNAL_AFTER_APPLY: signal }, + ); + assert.equal( + signalFailure.status, + expectedStatus, + `${signal} during apply must exit with the signal status`, + ); + assert.match(signalFailure.stderr, /restoring the original clean tree/); + assert.equal(runGit("rev-parse", "HEAD"), signalFailureHead, `${signal} must not move HEAD`); + assert.equal(runGit("status", "--short"), "", `${signal} must restore index and worktree`); + const signalRolledBackSkill = JSON.parse( + await readFile(path.join(skillDir, "skill.json"), "utf8"), + ); + assert.equal( + signalRolledBackSkill.version, + "0.0.1", + `${signal} must restore original metadata`, + ); + } + + const preCommitHook = path.join(fixtureRoot, ".git", "hooks", "pre-commit"); + await writeFile( + preCommitHook, + "#!/bin/sh\nif [ \"${CLAWSEC_TEST_FAIL_COMMIT:-}\" = \"1\" ]; then\n" + + " echo 'injected commit failure' >&2\n exit 1\nfi\n", + ); + await chmod(preCommitHook, 0o700); + const commitFailureHead = runGit("rev-parse", "HEAD"); + const commitFailure = run( + "bash", + ["scripts/release-skill.sh", "retired-skill", "0.0.2"], + { CLAWSEC_TEST_FAIL_COMMIT: "1" }, + ); + assert.equal(commitFailure.status, 1, "commit-hook failure must fail version preparation"); + assert.match(commitFailure.stderr, /injected commit failure/); + assert.match(commitFailure.stderr, /restoring the original clean tree/); + assert.equal(runGit("rev-parse", "HEAD"), commitFailureHead, "failed commit must not move HEAD"); + assert.equal(runGit("status", "--short"), "", "failed commit must restore index and worktree"); + const rolledBackSkill = JSON.parse(await readFile(path.join(skillDir, "skill.json"), "utf8")); + assert.equal(rolledBackSkill.version, "0.0.1", "failed commit must restore original version"); + const prepOnly = run("bash", ["scripts/release-skill.sh", "retired-skill", "0.0.2"]); assert.equal( prepOnly.status, @@ -102,7 +196,7 @@ try { assert.match(prepOnly.stdout, /signed denial evidence/); assert.match( prepOnly.stdout, - /Do not create or push a public tag, GitHub Release, or skill-store publication/, + /Do not request a public tag, GitHub Release, store publication, or catalog activation/, ); assert.equal(runGit("tag", "--list"), "", "review-only preparation must not create a tag"); assert.equal(existsSync(ghAttemptPath), false, "review-only preparation must not create a release"); diff --git a/scripts/test-skill-release-workflow.mjs b/scripts/test-skill-release-workflow.mjs index 782bc461..834ae9e7 100644 --- a/scripts/test-skill-release-workflow.mjs +++ b/scripts/test-skill-release-workflow.mjs @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -const workflowPath = new URL('../.github/workflows/skill-release.yml', import.meta.url); +const workflowPath = new URL('../.github/workflows/controlled-skill-release.yml', import.meta.url); const ciWorkflowPath = new URL('../.github/workflows/ci.yml', import.meta.url); const clawhubLockPath = new URL('../.github/clawhub-cli/package-lock.json', import.meta.url); const validateSkillInstallDocsPath = new URL('./ci/validate_skill_install_docs.mjs', import.meta.url); @@ -86,7 +86,7 @@ for (const generatedFeedPath of [ assert.match( workflow, - /pull_request:[\s\S]*paths:[\s\S]*- '\.github\/workflows\/skill-release\.yml'[\s\S]*- 'scripts\/ci\/\*\*'/, + /pull_request:[\s\S]*paths:[\s\S]*- '\.github\/workflows\/controlled-skill-release\.yml'[\s\S]*- '\.github\/workflows\/create-skill-release-tag\.yml'[\s\S]*- 'scripts\/ci\/\*\*'/, 'Skill release workflow must also run when the release pipeline itself changes', ); @@ -531,7 +531,7 @@ assert.match( assert.ok( workflow.includes('simulated_version | test("^[0-9]+\\\\.[0-9]+\\\\.[0-9]+(-[a-zA-Z0-9]+)?$")'), - 'Skill release workflow must accept every prerelease version format that release-skill.sh accepts', + 'PR release simulation must continue recognizing legacy candidate versions while public preparation is stable-only', ); assert.ok( @@ -544,34 +544,49 @@ assert.ok( 'release-skill.sh must update hardcoded release verification VERSION assignments when bumping a skill', ); -const releaseScriptInstallabilityIndex = requiredIndex( +const releaseScriptPolicyIndex = requiredIndex( releaseSkillScript, - 'node scripts/ci/skill_installability.mjs "$SKILL_PATH" --require-publication', - 'release-skill.sh must enforce publication eligibility in full-release mode', + 'node scripts/ci/stable_tag_policy.mjs --tag "$TAG"', + 'release-skill.sh must enforce the shared stable policy before preparation', ); for (const [needle, description] of [ - ['jq --arg v "$VERSION"', 'version mutation'], - ['git add "$file"', 'staging'], + ['jq --arg version "$VERSION"', 'version mutation'], + ['git add -- "$file"', 'staging'], ['git commit -m', 'commit creation'], - ['git tag -a "$TAG"', 'tag creation'], - ['gh release create "$TAG"', 'GitHub release creation'], ]) { assert.ok( - releaseScriptInstallabilityIndex + releaseScriptPolicyIndex < requiredIndex(releaseSkillScript, needle, `release-skill.sh must contain ${description}`), - `release-skill.sh must reject a non-installable full release before ${description}`, + `release-skill.sh must enforce final-version policy before ${description}`, ); } assert.match( releaseSkillScript, - /if \[\[ "\$IS_RELEASE_BRANCH" == "true" \|\| "\$FORCE_TAG" == "true" \]\]; then[\s\S]*skill_installability\.mjs "\$SKILL_PATH" --require-publication[\s\S]*else[\s\S]*skill_installability\.mjs "\$SKILL_PATH"/, - 'release-skill.sh must validate all modes while requiring publication eligibility only for tag-capable mode', + /git status --porcelain=v1 --untracked-files=all[\s\S]*jq --arg version "\$VERSION"/, + 'release-skill.sh must reject globally dirty source before version mutation', ); assert.match( releaseSkillScript, - /if \[\[ "\$INSTALLABLE" == "false" \]\]; then[\s\S]*signed denial evidence[\s\S]*Do not create or push a public tag, GitHub Release, or skill-store publication/, + /restoring the original clean tree[\s\S]*cp -p "\$\{BACKUP_FILES\[\$index\]\}" "\$\{TARGET_FILES\[\$index\]\}"[\s\S]*git add -- "\$\{TARGET_FILES\[@\]\}"[\s\S]*trap cleanup EXIT/, + 'release-skill.sh must restore both worktree files and index state when preparation fails', +); +assert.match( + releaseSkillScript, + /PREPARATION_COMPLETED=false[\s\S]*\[ "\$PREPARATION_COMPLETED" != "true" \][\s\S]*trap 'handle_signal 129' HUP[\s\S]*trap 'handle_signal 130' INT[\s\S]*trap 'handle_signal 143' TERM[\s\S]*PREPARATION_COMPLETED=true/, + 'release-skill.sh must roll back incomplete preparation after fatal signals', +); +assert.match( + releaseSkillScript, + /if \[ "\$INSTALLABLE" = "false" \]; then[\s\S]*signed denial evidence[\s\S]*Do not request a public tag, GitHub Release, store publication, or catalog activation/, 'Non-installable prep mode must not print follow-up instructions that authorize publication', ); +for (const forbidden of ['--force-tag', 'git tag', 'gh release create', 'git reset --hard']) { + assert.equal( + releaseSkillScript.includes(forbidden), + false, + `release-skill.sh must not retain direct release primitive: ${forbidden}`, + ); +} assert.match( workflow, diff --git a/scripts/test-skill-stable-tag-policy.mjs b/scripts/test-skill-stable-tag-policy.mjs index b8dee8b9..069bcc5c 100644 --- a/scripts/test-skill-stable-tag-policy.mjs +++ b/scripts/test-skill-stable-tag-policy.mjs @@ -25,7 +25,7 @@ import { const helperPath = fileURLToPath(new URL("./ci/stable_tag_policy.mjs", import.meta.url)); const workflowPath = fileURLToPath( - new URL("../.github/workflows/skill-release.yml", import.meta.url), + new URL("../.github/workflows/controlled-skill-release.yml", import.meta.url), ); const releaseScriptPath = fileURLToPath(new URL("./release-skill.sh", import.meta.url)); const inventoryPath = fileURLToPath( @@ -263,10 +263,11 @@ assert.match(policyJob, /permissions:\n\s+contents: read/); assert.doesNotMatch(policyJob, /contents: write|pages: write|id-token: write|secrets\.|CLAWHUB_TOKEN/); assert.match(policyJob, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); assert.match(policyJob, /fetch-depth: 0[\s\S]*persist-credentials: false/); -assert.match(policyJob, /REQUESTED_TAG: \$\{\{ github\.event_name == 'workflow_dispatch'/); +assert.match(policyJob, /REQUESTED_TAG: \$\{\{ inputs\.tag \}\}/); assert.match(policyJob, /stable_tag_policy\.mjs --tag "\$REQUESTED_TAG"/); assert.match(policyJob, /git show-ref --verify --quiet "refs\/tags\/\$\{validated_tag\}"/); -assert.match(policyJob, /"\$REQUESTED_REF" = "refs\/tags\/\$\{validated_tag\}"/); +assert.match(policyJob, /"\$REQUESTED_REF" = "refs\/heads\/\$DEFAULT_BRANCH"/); +assert.match(policyJob, /controlled_tag_creation\.mjs verify-release/); assert.match(releaseJob, /needs: stable-publication-policy/); assert.match(releaseJob, /permissions:\n\s+contents: write/); @@ -274,8 +275,9 @@ assert.match(releaseJob, /VALIDATED_TAG: \$\{\{ needs\.stable-publication-policy assert.doesNotMatch(releaseJob, /github\.ref_name|github\.event\.inputs\.tag/); assert.match( releaseJob, - /ref: refs\/tags\/\$\{\{ needs\.stable-publication-policy\.outputs\.tag \}\}/, + /ref: \$\{\{ needs\.stable-publication-policy\.outputs\.peeled_commit_oid \}\}/, ); +assert.match(releaseJob, /inputs\.operation == 'controlled_release'/); assert.match(releaseJob, /tag_name: \$\{\{ needs\.stable-publication-policy\.outputs\.tag \}\}/); assert.match(releaseJob, /prerelease: false/); @@ -284,11 +286,13 @@ assert.match(publishJob, /needs\.stable-publication-policy\.outputs\.publication assert.match(publishJob, /CLAWHUB_TOKEN: \$\{\{ secrets\.CLAWHUB_TOKEN \}\}/); assert.match( publishJob, - /ref: refs\/tags\/\$\{\{ needs\.stable-publication-policy\.outputs\.tag \}\}/, + /ref: \$\{\{ needs\.stable-publication-policy\.outputs\.peeled_commit_oid \}\}/, ); +assert.match(publishJob, /inputs\.operation == 'controlled_release'/); assert.match(republishJob, /needs: stable-publication-policy/); assert.match(republishJob, /needs\.stable-publication-policy\.outputs\.publication_eligible == 'true'/); +assert.match(republishJob, /inputs\.operation == 'republish_clawhub'/); assert.match(republishJob, /VALIDATED_TAG: \$\{\{ needs\.stable-publication-policy\.outputs\.tag \}\}/); assert.doesNotMatch(republishJob, /github\.event\.inputs\.tag|github\.ref_name/); assert.match( @@ -313,18 +317,23 @@ const scriptPolicyIndex = requiredIndex( "release-skill.sh must call the shared stable tag policy", ); for (const [needle, operation] of [ - ["TEMP_DIR=$(mktemp -d)", "temporary mutation staging"], - ['jq --arg v "$VERSION"', "skill metadata write"], - ['git add "$file"', "Git staging"], + ['TEMP_DIR="$(mktemp -d)"', "temporary mutation staging"], + ['jq --arg version "$VERSION"', "skill metadata write"], + ['git add -- "$file"', "Git staging"], ['git commit -m "chore($SKILL_NAME): bump version to $VERSION"', "Git commit"], - ['git tag -a "$TAG"', "tag creation"], - ['gh release create "$TAG"', "GitHub Release creation"], ]) { assert.ok( scriptPolicyIndex < requiredIndex(releaseScript, needle, `${operation} must remain present`), `stable tag policy must run before ${operation}`, ); } +for (const forbidden of ["--force-tag", "git tag", "gh release create", "git reset --hard"]) { + assert.equal( + releaseScript.includes(forbidden), + false, + `version-preparation helper must not contain ${forbidden}`, + ); +} assert.doesNotMatch( releaseScript, /Validate semver format \(supports prerelease|Invalid version format\. Use semver|if ! \[\[ "\$VERSION" =~/, @@ -370,6 +379,10 @@ try { path.join(fixtureSkillDir, "skill.json"), `${JSON.stringify({ name: "stable-skill", version: "0.1.0", installable: true }, null, 2)}\n`, ), + writeFile( + path.join(fixtureSkillDir, "SKILL.md"), + "---\nname: stable-skill\nversion: 0.1.0\n---\n\n# Stable skill\n", + ), writeFile( path.join(fakeBinDir, "gh"), `#!/bin/sh\nprintf attempted > "${ghAttemptPath}"\nexit 0\n`,