Claude Skill

lov-release-via-cicd

配置或运行现有项目发布流水线,并回读产物与真实上线状态。支持明确输入与结果回读。Use to set up or run a verified

LLM Mart · 0 points · 9 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lovstudio-skills-skills_release-via-cicd-0b16007.zip · 28 KB
Part of lovstudio/skills — 83 skills

Install

skills CLI npx skills add https://github.com/lovstudio/skills/tree/main/skills/release-via-cicd
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
Git git clone https://github.com/lovstudio/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lovstudio/skills collection as a plugin from our marketplace. Git is the plain clone.

README

自动发版 · Release Pilot

Version

配置或运行现有项目发布流水线,并回读产物与真实上线状态。

安装

npx skills add lov-release-via-cicd -g -y

使用

  • 配置或运行现有项目发布流水线,并回读产物与真实上线状态。
  • Set up or run a verified CI/CD release.

支持自然语言调用,具体执行步骤与边界见 SKILL.md。分析、预览和实际修改分开处理。

配置与依赖

读取 共享 Profile;项目路径、输出目录与品牌来自当前请求或用户配置。 业务工具和目标系统依赖见工作流;公开文案使用 lov-branding-consistency 审校。

质量与案例

python3 scripts/validate_skill.py .

案例 记录真实迁移输入和输出,仅证明所声明的验收范围。 Skill Card 与 定价依据 说明使用边界;业务运行结果须逐次回读。

License

MIT

Skill manifest

自动发版 · Release Pilot

配置或运行现有项目发布流水线,并回读产物与真实上线状态。

Triggers

Activate when

  • “配置或运行现有项目发布流水线,并回读产物与真实上线状态。”
  • “Set up or run a verified CI/CD release.”

Do not activate when

  • 只是查询本 Skill 的说明,或请求与上述结果无关的任务;不执行实际业务操作。
  • 用户仅要预览或审查时,不进入修改、提交或发布分支。

Execution boundary

自然语言请求即可触发;无需旧 slash 路径、参数插值或指定助手。明确解析当前请求中的 项目、目标文件、选项与输出位置;用当前宿主实际提供的文件、搜索、CLI 和浏览器能力。 项目依赖版本与外部 API 在执行时核实,不能假设示例是现行配置。随包脚本从 Skill 根解析, 业务文件从目标项目根解析。先读当前状态,保护已有未提交内容与其他任务的暂存区。 分析、预览请求保持只读;修改、提交、推送、部署和发布各依当前请求的明确范围执行。 不绕过保护、自动发送消息、强制结束用户进程或抢前台。失败保留可诊断原始错误。

Workflow

  1. 区分 setup、publish、setup+publish、local、ci、ci-auto 与 status;仅审计或配置不授权发布。读取项目语言、包管理器、版本、CHANGELOG、分支、remote、保护规则及现有 workflows。

  2. 保留当前发布方式,配置缺口先按项目事实修复。迁移 semantic-release 到 changesets 是独立行为,只有用户要求时实施;不无条件安装 changesets。

  3. 发布前审阅目标差异和已暂存文件,只提交本次发布涉及文件。脏工作区不得自动 git add -A,不能强制切主分支、自动吞掉提交失败或覆盖其他任务。

  4. 默认按当前版本补丁递增,0.x 保持 0.x;破坏性升级按用户指定处理。同步所有实际版本源、lockfile 和 CHANGELOG,发布说明从已核验变更生成,不虚构历史。

  5. 分支发布遵守保护与 PR 流程,必要时使用独立工作树;不能绕过评审、hooks、签名或非快进保护。已发 tag 不改写,缺漏用补丁版修复。

  6. Node/monorepo 依据配置生成 changeset 并运行 version;Shell 或其他项目按既有版本文件与 tag 规则处理。推 tag 和触发 workflow 只在发布授权的目标范围内执行。

  7. CI 参数、action 与签名配置按当前官方和项目既有实现核验。桌面或 Tauri 项目完整读取 references/tauri-release-workflow.md;其他项目读取 references/general-release-playbooks.md。

  8. 等待已触发 run 的最终状态,记录具体 run ID、commit、tag 和失败日志;较长过程使用宿主等待或监控工具,不以排队视为完成。

  9. 回读 Release 与附件,桌面验证签名、公证、安装包内 App 和更新链;需要区域镜像时只在主发布和验证通过后同步,不能让镜像失败掩盖主产物状态。

  10. 只在明确授权时向相关 issue 发消息或关闭;发布本身不自动授权对外评论。报告配置、构建、发布、官网/商店与安装验证各层真实结果。

Composition

执行前读取 能力组合,按明确制品交接相邻能力。

Runtime context (shared)

运行前读取本包 skill.yaml 与 Profile 合同。优先级为当前请求、 项目上下文、本 Skill records、共享 preferences、brand/user Profile、安全默认值。 只读取声明字段;没有专用运行时的宿主可使用 scripts/profile_store.py 读取共享 Profile。 配置缺失只问影响结果的一个问题。用户明确要求长期保存的值通过该脚本原子写入, 报告实际路径;不保存推断、凭据或其他任务的资料。

通用反馈闭环

用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:

  1. 先判断意见是 task-specific(仅本次)还是 reusable(可跨任务复用)。
  2. task-specific 只修改当前任务,不改 Skill。
  3. reusable 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。
  4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
  5. reusable 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
Files (skills)
  • cases
    • cases.json 653 B
      [
        {
          "title": "历史命令通用化迁移验证",
          "description": "真实迁移请求的结构与可发现性验收;业务系统操作没有在本案例执行。",
          "input": {
            "source": "历史命令或本地已迁移 Skill",
            "sha256": "bf09d4b98f884018ef02ea73f4400e440280e1278031d3bb0da2dde40d3b6702"
          },
          "prompt": "将本地所有历史 slash command 逐一升级,并全部同步官网。",
          "output": {
            "artifact": "SKILL.md",
            "result": "显式输入、自然语言触发、跨宿主执行边界及共享 Profile 合同",
            "limitation": "该案例不证明外部业务写入成功"
          }
        }
      ]
      
  • references
    • general-release-playbooks.md 10.2 KB
      # General Release Playbooks
      
      Use this file for non-Tauri templates and operational checklists. Keep the main
      `SKILL.md` focused on decision rules.
      
      ## Shell Project Workflow
      
      ```yaml
      name: Release
      on:
        push:
          tags: ['v*']
        workflow_dispatch:
          inputs:
            tag:
              description: 'Tag (e.g. v1.0.0)'
              required: true
      permissions:
        contents: write
      jobs:
        release:
          runs-on: ubuntu-latest
          steps:
            - uses: actions/checkout@v4
            - name: Get tag
              id: tag
              run: echo "tag=${{ github.event.inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT"
            - name: Extract release notes
              id: notes
              run: |
                VERSION="${{ steps.tag.outputs.tag }}"
                VERSION_NUM="${VERSION#v}"
                if [ -f CHANGELOG.md ]; then
                  NOTES=$(awk -v ver="$VERSION_NUM" '
                    /^## / { if (found) exit; if ($2 == ver) { found=1; next } }
                    found { print }
                  ' CHANGELOG.md)
                fi
                if [ -z "$NOTES" ]; then NOTES="Release $VERSION"; fi
                {
                  echo 'notes<<EOF'
                  echo "$NOTES"
                  echo 'EOF'
                } >> "$GITHUB_OUTPUT"
            - uses: softprops/action-gh-release@v2
              with:
                tag_name: ${{ steps.tag.outputs.tag }}
                body: ${{ steps.notes.outputs.notes }}
                files: |
                  *.sh
      ```
      
      ## Vite Frontend Workflow
      
      Use package-manager detection from the main skill. The artifact name must be
      `{project}-{tag}.zip`.
      
      ```yaml
      name: Release
      on:
        push:
          tags: ['v*']
        workflow_dispatch:
          inputs:
            tag:
              description: 'Tag (e.g. v1.0.0)'
              required: true
      permissions:
        contents: write
      jobs:
        release:
          runs-on: ubuntu-latest
          steps:
            - uses: actions/checkout@v4
              with:
                ref: ${{ github.event.inputs.tag || github.ref_name }}
            - uses: pnpm/action-setup@v4
            - uses: actions/setup-node@v4
              with:
                node-version: 20
                cache: pnpm
            - run: pnpm install --frozen-lockfile
            - run: pnpm build
            - name: Package dist
              run: |
                TAG="${{ github.event.inputs.tag || github.ref_name }}"
                zip -r "${PROJECT_NAME}-${TAG}.zip" dist
              env:
                PROJECT_NAME: myproject
            - name: Get tag
              id: tag
              run: echo "tag=${{ github.event.inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT"
            - name: Extract release notes
              id: notes
              run: |
                VERSION="${{ steps.tag.outputs.tag }}"
                VERSION_NUM="${VERSION#v}"
                if [ -f CHANGELOG.md ]; then
                  NOTES=$(awk -v ver="$VERSION_NUM" '
                    /^## / { if (found) exit; if ($2 == ver) { found=1; next } }
                    found { print }
                  ' CHANGELOG.md)
                fi
                if [ -z "$NOTES" ]; then NOTES="Release $VERSION"; fi
                {
                  echo 'notes<<EOF'
                  echo "$NOTES"
                  echo 'EOF'
                } >> "$GITHUB_OUTPUT"
            - uses: softprops/action-gh-release@v2
              with:
                tag_name: ${{ steps.tag.outputs.tag }}
                body: ${{ steps.notes.outputs.notes }}
                files: ${{ env.PROJECT_NAME }}-${{ steps.tag.outputs.tag }}.zip
              env:
                PROJECT_NAME: myproject
      ```
      
      ## Monorepo Changesets Workflow
      
      ```yaml
      name: Release
      on:
        push:
          branches: [main]
      concurrency:
        group: ${{ github.workflow }}-${{ github.ref }}
        cancel-in-progress: true
      jobs:
        release:
          runs-on: ubuntu-latest
          permissions:
            contents: write
            pull-requests: write
          steps:
            - uses: actions/checkout@v4
              with:
                fetch-depth: 0
            - uses: pnpm/action-setup@v4
            - uses: actions/setup-node@v4
              with:
                node-version: 20
                cache: pnpm
                registry-url: 'https://registry.npmjs.org'
            - run: pnpm install --frozen-lockfile
            - run: pnpm build
            - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
            - uses: changesets/action@v1
              id: changesets
              with:
                version: pnpm changeset version
                publish: pnpm release
                title: 'chore: release packages'
                commit: 'chore: release packages'
              env:
                GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
                NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
            - name: Pack npm packages
              if: steps.changesets.outputs.published == 'true'
              run: |
                mkdir -p release-assets
                for pkg in packages/*/; do
                  if [ -f "$pkg/package.json" ]; then
                    cd "$pkg"
                    npm pack --pack-destination ../../release-assets
                    cd ../..
                  fi
                done
            - name: Create GitHub Release
              if: steps.changesets.outputs.published == 'true'
              run: |
                VERSION=$(node -p "require('./packages/core/package.json').version")
                NOTES=$(awk -v ver="$VERSION" '
                  /^## / { if (found) exit; if ($2 == ver) { found=1; next } }
                  found { print }
                ' packages/core/CHANGELOG.md)
                gh release create "v${VERSION}" \
                  --title "v${VERSION}" \
                  --notes "${NOTES:-Release v${VERSION}}" \
                  --latest \
                  release-assets/*.tgz
              env:
                GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      ```
      
      ## Workflow Monitor
      
      Always wait for workflow completion. `gh run view` can fail with transient API
      EOF; retry before treating the run as unknown.
      
      ```bash
      RUN_ID=$(gh run list -w release.yml -L 1 --json databaseId -q '.[0].databaseId')
      REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
      echo "Workflow: https://github.com/$REPO/actions/runs/$RUN_ID"
      
      gh_view_json() {
        local run_id="$1"
        local json="$2"
        local query="$3"
        local attempt=1
        while [ "$attempt" -le 5 ]; do
          if gh run view "$run_id" --json "$json" -q "$query"; then
            return 0
          fi
          sleep $((attempt * 2))
          attempt=$((attempt + 1))
        done
        return 1
      }
      
      DELAY=5
      MAX_DELAY=60
      while true; do
        STATUS=$(gh_view_json "$RUN_ID" status '.status') || {
          echo "GitHub API still unavailable; next poll in ${DELAY}s..."
          sleep "$DELAY"
          DELAY=$((DELAY * 2 > MAX_DELAY ? MAX_DELAY : DELAY * 2))
          continue
        }
        if [ "$STATUS" = "completed" ]; then
          CONCLUSION=$(gh_view_json "$RUN_ID" conclusion '.conclusion' || echo "unknown")
          echo "Workflow $CONCLUSION"
          [ "$CONCLUSION" = "success" ] || gh_view_json "$RUN_ID" jobs '.jobs[] | select(.conclusion != "success") | "  \(.name): \(.conclusion)"' || true
          break
        fi
        sleep "$DELAY"
        DELAY=$((DELAY * 2 > MAX_DELAY ? MAX_DELAY : DELAY * 2))
      done
      ```
      
      ## Post-release Mirror Separation
      
      Treat regional and community mirrors as derived distribution surfaces. Keep them outside the primary release DAG:
      
      ```text
      draft release -> build/sign/notarize -> upload canonical assets -> publish release
                                                                     -> dispatch mirror workflow
      ```
      
      Never upload a mirror from platform build jobs and never make `publish-release` depend on mirror completion. Dispatch a separate workflow after the canonical release becomes public:
      
      ```yaml
      permissions:
        actions: write
        contents: write
      
      jobs:
        publish-release:
          steps:
            - name: Publish release
              env:
                GH_TOKEN: ${{ github.token }}
              run: gh release edit "$RELEASE_TAG" -R "$GITHUB_REPOSITORY" --draft=false --latest
      
            - name: Dispatch regional mirror post-CI
              env:
                GH_TOKEN: ${{ github.token }}
              run: |
                if ! gh workflow run release-mirror.yml -R "$GITHUB_REPOSITORY" -f tag="$RELEASE_TAG"; then
                  echo "::warning::Mirror dispatch failed; the canonical release remains successful."
                fi
      ```
      
      Make the mirror workflow manually retryable and source every file from the immutable published tag:
      
      ```yaml
      name: Release Post-CI (Mirror)
      on:
        workflow_dispatch:
          inputs:
            tag:
              required: true
      permissions:
        contents: read
      jobs:
        mirror:
          runs-on: ubuntu-latest
          steps:
            - uses: actions/checkout@v4
              with:
                ref: ${{ inputs.tag }}
            - name: Download canonical assets
              env:
                GH_TOKEN: ${{ github.token }}
              run: |
                mkdir -p release
                gh release download "${{ inputs.tag }}" -R "$GITHUB_REPOSITORY" --dir release --clobber
            - name: Upload mirror
              run: ./scripts/upload-mirror.sh release
      ```
      
      For large multi-platform releases, fan the post-CI workflow out by platform. Download and upload only that platform's assets in each job, set `fail-fast: false`, and keep mirror retries independent from the canonical release.
      
      Wait for and verify the canonical workflow first. Monitor the mirror workflow separately; report its failure without redefining the already-published canonical Release as failed.
      
      ## README And Vercel Audits
      
      After workflow success, compare the released version with README versions:
      
      ```bash
      NEW_VERSION=$(node -p "require('./package.json').version" 2>/dev/null \
        || git tag -l 'v*' | sort -V | tail -1 | sed 's/^v//')
      README_LATEST=$(grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' README.md 2>/dev/null \
        | sed 's/^v//' | sort -V | tail -1)
      test -z "$README_LATEST" || test "$README_LATEST" = "$NEW_VERSION" || echo "README stale"
      ```
      
      For Vercel projects, check `.vercel/` after push, wait briefly for Git
      Integration, and run `vercel --prod` only if no fresh deployment appears.
      
      ## Failure Recovery Notes
      
      | Trap | Recovery |
      |------|----------|
      | Tag pushed but build does not trigger | Use `gh workflow run release.yml -f tag=vX.Y.Z` |
      | Draft release left by failed run | `gh release delete vX.Y.Z --yes`, then rerun |
      | Need to delete tag too | Add `--cleanup-tag` only when explicitly deleting the tag |
      | Release notes show `%0A` or shell EOF | Use `$GITHUB_OUTPUT` block writes, not fragile multiline shell assignments |
      | Release notes are blank or commit-only | Maintain `CHANGELOG.md`; do not use `generate_release_notes` |
      | Bun project changed to pnpm | Preserve `packageManager`; use `oven-sh/setup-bun@v2` |
      | User edits during workflow | Keep release tied to tag commit; new dirty work goes to next version |
      | macOS asset might be unsigned | Download and verify with `codesign` and `spctl` |
      
      ## Branch And Issue Automation
      
      Recognize issue numbers from `issue-123`, `123-feature`, and
      `feature/issue-123`. After a successful release, comment with the release URL
      and close only if the issue is still open.
      
    • skill-composition.md 861 B
      # Skill Group Composition
      
      ## Nearby Skills Inspected
      
      - lov-skill-creator:命令迁移与通用结构;不拥有本业务结果。
      - lov-branding-consistency:用户可见文案审校。
      - lov-skill-publisher:已验证 Skill 的官网分发。
      
      ## Atomic Handoffs
      
      本 Skill 接收请求与项目事实,交付配置或运行现有项目发布流水线,并回读产物与真实上线状态。文案审校只消费公开文本;发布器只消费校验后的 Skill 源,不接管业务执行。
      
      ## Overlap Decisions
      
      旧命令按实际输入输出映射到本真源;不新增另一份重复业务实现。其他历史能力在本批次逐项比较,只有用户要求相邻结果时交接。
      
      ## Composition Decision
      
      Single Skill:围绕同一结果的预览、执行和验证共享上下文;不把依赖工具或宿主适配误拆成 Kit。
      
    • tauri-release-workflow.md 11.8 KB
      # Tauri Release Workflow Reference
      
      Use this reference when creating or repairing `.github/workflows/release.yml`
      for Tauri projects.
      
      ## Required Shape
      
      - Create one draft release first, build all platform assets into that draft, then publish the draft only after all required build jobs pass.
      - Dispatch regional mirrors only after publishing. Run mirror synchronization in a separate, independently retryable post-CI workflow so it cannot block platform builds or the public Release.
      - Do not rely on workflow success alone. After publishing, download release assets and verify macOS signing/notarization locally.
      - Do not add unsigned macOS `xattr` notes when Developer ID signing and notarization are configured.
      - Windows fallback is allowed: if WiX MSI or NSIS hangs/fails, build the executable with `tauri build --no-bundle` and upload a zip.
      
      ## Apple Signing Preflight
      
      Check local signing identity:
      
      ```bash
      security find-identity -v -p codesigning | grep "Developer ID Application" || true
      ```
      
      Check GitHub secrets without printing values:
      
      ```bash
      for name in \
        APPLE_CERTIFICATE \
        APPLE_CERTIFICATE_PASSWORD \
        APPLE_SIGNING_IDENTITY \
        APPLE_ID \
        APPLE_PASSWORD \
        APPLE_TEAM_ID
      do
        gh secret list | awk '{print $1}' | grep -qx "$name" \
          && echo "ok $name" \
          || echo "missing $name"
      done
      ```
      
      `APPLE_PASSWORD` must be an Apple app-specific password. If the local variable is named `APPLE_SPECIFIC_APP_PASSWORD`, store it in GitHub as `APPLE_PASSWORD`.
      
      The p12 used for `APPLE_CERTIFICATE` must contain the Developer ID identity only. If a p12 contains both `Apple Development` and `Developer ID Application`, Tauri can pick the wrong certificate and fail with an identity mismatch.
      
      Developer ID-only export pattern:
      
      ```bash
      TMP_DIR=$(mktemp -d)
      TMP_KEYCHAIN="$TMP_DIR/developer-id.keychain-db"
      trap 'security delete-keychain "$TMP_KEYCHAIN" 2>/dev/null || true; rm -rf "$TMP_DIR"' EXIT
      
      security create-keychain -p "$KEYCHAIN_PASSWORD" "$TMP_KEYCHAIN"
      security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$TMP_KEYCHAIN"
      security import all-identities.p12 \
        -k "$TMP_KEYCHAIN" \
        -P "$APPLE_CERTIFICATE_PASSWORD" \
        -T /usr/bin/codesign \
        -T /usr/bin/security
      
      security find-certificate -a -c "Apple Development" -Z "$TMP_KEYCHAIN" \
        | awk '/SHA-1 hash:/ {print $3}' \
        | while read -r sha; do
            security delete-certificate -Z "$sha" "$TMP_KEYCHAIN"
          done
      
      security export \
        -k "$TMP_KEYCHAIN" \
        -t identities \
        -f pkcs12 \
        -P "$APPLE_CERTIFICATE_PASSWORD" \
        -o "$TMP_DIR/developer-id-only.p12"
      
      base64 -i "$TMP_DIR/developer-id-only.p12" -o "$TMP_DIR/developer-id-only.p12.base64"
      gh secret set APPLE_CERTIFICATE < "$TMP_DIR/developer-id-only.p12.base64"
      ```
      
      Set the remaining secrets without echoing values:
      
      ```bash
      gh secret set APPLE_CERTIFICATE_PASSWORD --body "$APPLE_CERTIFICATE_PASSWORD"
      gh secret set APPLE_SIGNING_IDENTITY --body "$APPLE_SIGNING_IDENTITY"
      gh secret set APPLE_ID --body "$APPLE_ID"
      gh secret set APPLE_PASSWORD --body "$APPLE_SPECIFIC_APP_PASSWORD"
      gh secret set APPLE_TEAM_ID --body "$APPLE_TEAM_ID"
      ```
      
      ## Release Notes Output
      
      Use a helper that writes valid `$GITHUB_OUTPUT` blocks. Avoid shell variables that contain hand-indented markdown blocks inside YAML because they often produce `unexpected EOF` when copied or reindented.
      
      ```bash
      append_output_block() {
        local name="$1"
        local delimiter="EOF_${name}_$(date +%s)"
        {
          printf '%s<<%s\n' "$name" "$delimiter"
          cat
          printf '%s\n' "$delimiter"
        } >> "$GITHUB_OUTPUT"
      }
      ```
      
      Unsigned warning is conditional:
      
      ```bash
      if [ "$HAS_APPLE_SIGNING" != "true" ]; then
        {
          printf '\n---\n\n'
          printf '**macOS 用户注意**: 本应用暂未签名,首次运行需授权:\n\n'
          printf '```bash\n'
          printf 'sudo xattr -dr com.apple.quarantine /Applications/%s.app\n' "$APP_NAME"
          printf '```\n'
        } >> notes.md
      fi
      ```
      
      ## Workflow Template
      
      ```yaml
      name: Release
      on:
        workflow_dispatch:
          inputs:
            tag:
              description: 'Tag (e.g. v0.1.0)'
              required: true
      
      permissions:
        contents: write
      
      jobs:
        create-release:
          runs-on: ubuntu-latest
          outputs:
            release_id: ${{ steps.create.outputs.id }}
            tag: ${{ github.event.inputs.tag }}
          steps:
            - uses: actions/checkout@v4
              with:
                ref: ${{ github.event.inputs.tag }}
      
            - name: Extract release notes
              id: notes
              shell: bash
              env:
                APP_NAME: ${{ github.event.repository.name }}
                HAS_APPLE_SIGNING: ${{ secrets.APPLE_CERTIFICATE != '' && secrets.APPLE_CERTIFICATE_PASSWORD != '' && secrets.APPLE_SIGNING_IDENTITY != '' && secrets.APPLE_ID != '' && secrets.APPLE_PASSWORD != '' && secrets.APPLE_TEAM_ID != '' }}
              run: |
                set -euo pipefail
                VERSION="${{ github.event.inputs.tag }}"
                VERSION_NUM="${VERSION#v}"
      
                awk -v ver="$VERSION_NUM" '
                  /^## / { if (found) exit; if ($2 == ver) { found=1; next } }
                  found { print }
                ' CHANGELOG.md > notes.md || true
      
                if [ ! -s notes.md ]; then
                  printf 'Release %s\n' "$VERSION" > notes.md
                fi
      
                if [ "$HAS_APPLE_SIGNING" != "true" ]; then
                  {
                    printf '\n---\n\n'
                    printf '**macOS 用户注意**: 本应用暂未签名,首次运行需授权:\n\n'
                    printf '```bash\n'
                    printf 'sudo xattr -dr com.apple.quarantine /Applications/%s.app\n' "$APP_NAME"
                    printf '```\n'
                  } >> notes.md
                fi
      
                delimiter="EOF_notes_$(date +%s)"
                {
                  printf 'notes<<%s\n' "$delimiter"
                  cat notes.md
                  printf '%s\n' "$delimiter"
                } >> "$GITHUB_OUTPUT"
      
            - id: create
              uses: softprops/action-gh-release@v2
              with:
                tag_name: ${{ github.event.inputs.tag }}
                draft: true
                body: ${{ steps.notes.outputs.notes }}
      
        build-tauri:
          needs: create-release
          strategy:
            fail-fast: false
            matrix:
              include:
                - platform: macos-latest
                  label: macos-aarch64
                  args: --target aarch64-apple-darwin
                - platform: macos-latest
                  label: macos-x86_64
                  args: --target x86_64-apple-darwin
                - platform: ubuntu-22.04
                  label: linux-x86_64
                  args: ''
                - platform: windows-latest
                  label: windows-x86_64
                  args: ''
          runs-on: ${{ matrix.platform }}
          steps:
            - uses: actions/checkout@v4
              with:
                ref: ${{ github.event.inputs.tag }}
      
            - name: Detect package manager
              id: pm
              shell: bash
              run: |
                PM=$(node -p "const p=require('./package.json').packageManager||''; p.split('@')[0] || (require('fs').existsSync('bun.lock') || require('fs').existsSync('bun.lockb') ? 'bun' : require('fs').existsSync('pnpm-lock.yaml') ? 'pnpm' : require('fs').existsSync('yarn.lock') ? 'yarn' : 'npm')")
                echo "manager=$PM" >> "$GITHUB_OUTPUT"
      
            - if: steps.pm.outputs.manager == 'bun'
              uses: oven-sh/setup-bun@v2
      
            - if: steps.pm.outputs.manager == 'pnpm'
              uses: pnpm/action-setup@v4
      
            - uses: actions/setup-node@v4
              with:
                node-version: 20
      
            - uses: dtolnay/rust-toolchain@stable
              with:
                targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
      
            - if: matrix.platform == 'ubuntu-22.04'
              run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
      
            - uses: swatinem/rust-cache@v2
              with:
                workspaces: './src-tauri -> target'
      
            - name: Install dependencies
              shell: bash
              run: |
                case "${{ steps.pm.outputs.manager }}" in
                  bun) bun install --frozen-lockfile ;;
                  pnpm) pnpm install --frozen-lockfile ;;
                  yarn) yarn install --frozen-lockfile ;;
                  npm) npm ci ;;
                  *) echo "Unknown package manager" >&2; exit 1 ;;
                esac
      
            - name: Build Tauri bundles
              uses: tauri-apps/tauri-action@v0
              env:
                GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
                APPLE_CERTIFICATE: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_CERTIFICATE || '' }}
                APPLE_CERTIFICATE_PASSWORD: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
                APPLE_SIGNING_IDENTITY: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_SIGNING_IDENTITY || '' }}
                APPLE_ID: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_ID || '' }}
                APPLE_PASSWORD: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_PASSWORD || '' }}
                APPLE_TEAM_ID: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_TEAM_ID || '' }}
              with:
                releaseId: ${{ needs.create-release.outputs.release_id }}
                args: ${{ matrix.args }}
      
        windows-zip-fallback:
          needs: create-release
          runs-on: windows-latest
          if: false
          steps:
            - uses: actions/checkout@v4
              with:
                ref: ${{ github.event.inputs.tag }}
            - uses: oven-sh/setup-bun@v2
            - uses: actions/setup-node@v4
              with:
                node-version: 20
            - uses: dtolnay/rust-toolchain@stable
            - name: Build exe without bundle
              shell: pwsh
              run: |
                bun install --frozen-lockfile
                bun tauri build --no-bundle
                $tag = "${{ github.event.inputs.tag }}"
                $project = "${{ github.event.repository.name }}"
                $exe = Get-ChildItem "src-tauri\target\release" -Filter "*.exe" | Select-Object -First 1
                if (-not $exe) { throw "No exe found" }
                $zip = "$project-$tag-windows-x64.zip"
                Compress-Archive -Path $exe.FullName -DestinationPath $zip -Force
                gh release upload $tag $zip --repo ${{ github.repository }} --clobber
              env:
                GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      
        publish-release:
          needs: [create-release, build-tauri]
          runs-on: ubuntu-latest
          permissions:
            actions: write
            contents: write
          steps:
            - name: Publish release
              env:
                GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
              run: |
                gh release edit "${{ needs.create-release.outputs.tag }}" \
                  --repo "${{ github.repository }}" \
                  --draft=false
      
            - name: Dispatch regional mirror post-CI
              env:
                GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
              run: |
                tag="${{ needs.create-release.outputs.tag }}"
                if ! gh workflow run release-mirror.yml --repo "${{ github.repository }}" -f tag="$tag"; then
                  echo "::warning::Mirror dispatch failed; the canonical release remains successful."
                fi
      ```
      
      `windows-zip-fallback` is intentionally disabled in the template. Enable it only after a real WiX/NSIS failure, and then make `publish-release.needs` depend on the fallback job instead of the failed bundling job.
      
      When a mirror is configured, create the separately dispatched workflow from `references/general-release-playbooks.md`. Make it download canonical assets by tag instead of reusing build-job files.
      
      ## Failed Draft Recovery
      
      For a failed workflow that created a draft release:
      
      ```bash
      gh release delete vX.Y.Z --yes
      gh workflow run release.yml -f tag=vX.Y.Z
      ```
      
      Do not add `--cleanup-tag` unless the task is explicitly to delete the tag. If the tag must move before the release was published, force-push only for a just-created failed tag; otherwise bump patch and publish a new version.
      
      ## Verification
      
      Workflow success is not enough. Download the macOS asset and verify:
      
      ```bash
      codesign -dv --verbose=4 "/Applications/App Name.app" 2>&1 | grep -E 'Authority=Developer ID Application|TeamIdentifier'
      codesign --verify --deep --strict --verbose=2 "/Applications/App Name.app"
      spctl -a -vv -t exec "/Applications/App Name.app"
      ```
      
      Expected output includes:
      
      - `Authority=Developer ID Application`
      - `TeamIdentifier=<team id>`
      - `Notarization Ticket=stapled`
      - `source=Notarized Developer ID`
      
    • user-profile.md 2.7 KB
      # User Profile contract
      
      Every Skill created by Skill Creator declares `user-profile/v1` in `skill.yaml`.
      The contract connects independent sessions to one user-owned JSON Profile while
      keeping the Skill source portable across users and brands.
      
      ## Shared shape
      
      The host supplies the Profile through `SKILL_PROFILE_PATH` (or the runtime's
      configured profile path). The stable shared scopes are:
      
      - `user`: user identity, language, timezone, and other personal working defaults.
      - `brand`: public brand facts, site, logo, tone, profile, and design guidance.
      - `workspace`: project roots and output locations.
      - `preferences`: shared preference values when the host stores them in the Profile.
      - `skills.<skill_id>.profile`: Skill-specific defaults.
      - `skills.<skill_id>.records`: durable decisions and preferences learned from
        direct user statements for this Skill.
      
      The Profile may also use the runtime's canonical `identity` fields. Manifest
      field aliases bridge `identity.*` and the portable `user.*` / `brand.*` names.
      
      ## Read on every run
      
      1. Read the current request and project context.
      2. Read the shared Profile and the `skills.<skill_id>` namespace.
      3. Resolve values in this order: current request, project context, Skill records,
         shared preferences, shared user/brand Profile, safe defaults.
      4. Keep `profile_scope` and field provenance available for the final result.
      
      Do not copy resolved personal paths, brand values, or private records into the
      committed Skill source.
      
      ## Persist directly stated values
      
      When the user explicitly gives a value meant to survive later sessions, save it
      immediately after the user statement and report the canonical path:
      
      ```bash
      python3 scripts/profile_store.py record \
        --skill-id lov-example \
        --path records.subtitle_level \
        --value '"cet4"' \
        --confirm
      ```
      
      For shared facts, use `--path brand.<field>` or `--path user.<field>`. The
      script writes JSON atomically, preserves unrelated Profile data, increments a
      numeric Profile revision when present, and never echoes the stored value.
      
      Inferred information, credentials, tokens, cookies, and secret-like fields stay
      out of durable records. If the user has not stated that a value should persist,
      keep it in the current request context.
      
      ## Read the connected context
      
      ```bash
      python3 scripts/profile_store.py read \
        --skill-id lov-example \
        --pretty
      ```
      
      The result contains `user`, `brand`, `workspace`, `preferences`, `skill`, and
      `records` scopes. A host using `skill-runtime/v1` also returns the same binding
      as `profile_scope` and `profile_contract`.
      
      ## Compatibility
      
      `--user-config` remains accepted by the Creator as a compatibility flag for old
      invocations. The Profile contract is now always generated; users do not choose
      an initialization mode.
      
  • scripts
    • profile_store.py 8.3 KB
      #!/usr/bin/env python3
      """Read and persist a Skill's user-owned profile context.
      
      The command keeps shared identity and brand facts in the profile root while
      putting Skill-specific durable records under ``skills.<skill_id>.records``.
      Writes require an explicit confirmation flag and use an atomic replacement.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import re
      import sys
      import tempfile
      from pathlib import Path
      from typing import Any, Iterable
      
      
      USER_PROFILE_SCHEMA = "user-profile/v1"
      SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
      SAFE_ROOTS = {"user", "brand", "workspace", "preferences"}
      SENSITIVE_PARTS = {"token", "secret", "password", "credential", "cookie", "api_key", "apikey"}
      
      
      def config_dir() -> Path:
          configured = os.environ.get("SKILLS_CONFIG_DIR")
          if configured:
              return Path(os.path.expandvars(configured)).expanduser()
          xdg = os.environ.get("XDG_CONFIG_HOME")
          if xdg:
              return Path(os.path.expandvars(xdg)).expanduser() / "agent-skills"
          return Path.home() / ".config" / "agent-skills"
      
      
      def profile_path(explicit: Path | None = None) -> Path:
          if explicit:
              return explicit.expanduser()
          configured = (
              os.environ.get("SKILL_PROFILE_PATH")
              or os.environ.get("SKILLS_PROFILE_PATH")
          )
          if configured:
              return Path(os.path.expandvars(configured)).expanduser()
          candidates = (
              Path.home() / ".lovstudio" / "skills" / "profile.json",
              Path.home() / ".skill-publisher" / "skills" / "profile.json",
              config_dir() / "profile.json",
          )
          return next((candidate for candidate in candidates if candidate.exists()), candidates[-1])
      
      
      def read_profile(path: Path) -> dict[str, Any]:
          if not path.exists():
              return {}
          try:
              value = json.loads(path.read_text(encoding="utf-8"))
          except (OSError, json.JSONDecodeError) as exc:
              raise ValueError(f"profile cannot be read: {path}: {exc}") from exc
          if not isinstance(value, dict):
              raise ValueError("profile root must be an object")
          return value
      
      
      def profile_skeleton(skill_id: str) -> dict[str, Any]:
          return {
              "schema": "skill-profile/v1",
              "profile_id": f"profile-{skill_id}",
              "revision": 1,
              "status": "draft",
              "identity": {},
              "purpose": {},
              "brand": {},
              "user": {},
              "workspace": {},
              "skills": {},
              "extensions": {},
          }
      
      
      def atomic_write(path: Path, value: dict[str, Any]) -> None:
          path.parent.mkdir(parents=True, exist_ok=True)
          mode = 0o600
          if path.exists():
              mode = path.stat().st_mode & 0o777
          temporary_name: str | None = None
          try:
              with tempfile.NamedTemporaryFile(
                  "w",
                  encoding="utf-8",
                  dir=path.parent,
                  prefix=f".{path.name}.",
                  suffix=".tmp",
                  delete=False,
              ) as handle:
                  temporary_name = handle.name
                  handle.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
                  handle.flush()
                  os.fsync(handle.fileno())
              os.chmod(temporary_name, mode)
              os.replace(temporary_name, path)
          finally:
              if temporary_name:
                  temporary = Path(temporary_name)
                  if temporary.exists():
                      temporary.unlink()
      
      
      def set_path(root: dict[str, Any], parts: list[str], value: Any) -> None:
          current: dict[str, Any] = root
          for part in parts[:-1]:
              child = current.get(part)
              if not isinstance(child, dict):
                  child = {}
                  current[part] = child
              current = child
          current[parts[-1]] = value
      
      
      def target_parts(skill_id: str, requested_path: str) -> tuple[list[str], str]:
          if not SKILL_ID_RE.fullmatch(skill_id):
              raise ValueError("skill_id must be kebab-case")
          path = requested_path.strip().strip(".")
          if not path:
              raise ValueError("path is required")
          parts = [part for part in path.split(".") if part]
          if any(not re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in parts):
              raise ValueError("path contains an invalid segment")
          if any(part.casefold() in SENSITIVE_PARTS for part in parts):
              raise ValueError("profile records do not accept secret-like fields")
      
          if parts[0] == "records":
              target = ["skills", skill_id, "records", *parts[1:]]
              return target, ".".join(target)
          if parts[0] == "profile":
              target = ["skills", skill_id, "profile", *parts[1:]]
              return target, ".".join(target)
          if parts[:3] == ["skills", skill_id, "records"]:
              return parts, ".".join(parts)
          if parts[:3] == ["skills", skill_id, "profile"]:
              return parts, ".".join(parts)
          if parts[0] in SAFE_ROOTS:
              return parts, ".".join(parts)
          expected = f"records.<field> or one of: {', '.join(sorted(SAFE_ROOTS))}.<field>"
          raise ValueError(f"path must use {expected}")
      
      
      def parse_value(raw: str) -> Any:
          try:
              return json.loads(raw)
          except json.JSONDecodeError:
              return raw
      
      
      def read_command(args: argparse.Namespace) -> dict[str, Any]:
          path = profile_path(args.profile)
          profile = read_profile(path)
          skills = profile.get("skills", {})
          if not isinstance(skills, dict):
              skills = {}
          skill = skills.get(args.skill_id, {})
          if not isinstance(skill, dict):
              skill = {}
          user = profile.get("user")
          if not isinstance(user, dict) or not user:
              user = profile.get("identity", {})
          if not isinstance(user, dict):
              user = {}
          brand = profile.get("brand", {})
          if not isinstance(brand, dict):
              brand = {}
          workspace = profile.get("workspace", {})
          if not isinstance(workspace, dict):
              workspace = {}
          preferences = profile.get("preferences", {})
          if not isinstance(preferences, dict):
              preferences = {}
          records = skill.get("records", {})
          if not isinstance(records, dict):
              records = {}
          return {
              "status": "ready",
              "schema": USER_PROFILE_SCHEMA,
              "skill_id": args.skill_id,
              "profile_path": str(path),
              "user": user,
              "brand": brand,
              "workspace": workspace,
              "preferences": preferences,
              "skill": skill,
              "records": records,
          }
      
      
      def record_command(args: argparse.Namespace) -> dict[str, Any]:
          if not args.confirm:
              raise PermissionError("record requires --confirm after the user has stated the value")
          parts, canonical_path = target_parts(args.skill_id, args.path)
          path = profile_path(args.profile)
          profile = read_profile(path)
          if not profile:
              profile = profile_skeleton(args.skill_id)
          set_path(profile, parts, parse_value(args.value))
          revision = profile.get("revision")
          if isinstance(revision, int) and not isinstance(revision, bool):
              profile["revision"] = revision + 1
          atomic_write(path, profile)
          return {
              "status": "saved",
              "schema": USER_PROFILE_SCHEMA,
              "skill_id": args.skill_id,
              "profile_path": str(path),
              "path": canonical_path,
          }
      
      
      def main() -> int:
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("--profile", type=Path, default=None, help="Shared profile JSON path")
          subparsers = parser.add_subparsers(dest="command", required=True)
      
          read_parser = subparsers.add_parser("read", help="Read shared and Skill-specific profile context")
          read_parser.add_argument("--skill-id", required=True)
          read_parser.add_argument("--pretty", action="store_true")
      
          record_parser = subparsers.add_parser("record", help="Persist a user-stated profile value")
          record_parser.add_argument("--skill-id", required=True)
          record_parser.add_argument("--path", required=True, help="records.<field>, brand.<field>, or user.<field>")
          record_parser.add_argument("--value", required=True)
          record_parser.add_argument("--confirm", action="store_true")
      
          args = parser.parse_args()
          try:
              result = read_command(args) if args.command == "read" else record_command(args)
          except (OSError, PermissionError, ValueError) as exc:
              print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
              return 2
          indent = 2 if getattr(args, "pretty", False) else None
          print(json.dumps(result, ensure_ascii=False, indent=indent, sort_keys=bool(indent)))
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • validate_skill.py 26.2 KB
      #!/usr/bin/env python3
      """Validate a portable local Skill Publisher Skill source directory."""
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      from typing import Any, Iterable
      
      try:
          import yaml
      except ImportError:
          print(
              "ERROR: PyYAML is required. Install it with: python3 -m pip install PyYAML",
              file=sys.stderr,
          )
          raise SystemExit(2)
      
      
      FRONTMATTER_KEYS = {
          "name",
          "description",
          "license",
          "compatibility",
          "allowed-tools",
          "depends_on",
          "metadata",
      }
      TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"}
      JUNK_NAMES = {"__pycache__", ".DS_Store"}
      JUNK_SUFFIXES = {".pyc", ".pyo"}
      SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"}
      SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$")
      NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
      MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)")
      SKILL_PATH_RE = re.compile(r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)")
      CARD_STANDARD = "lovstudio/skill-card/v1"
      PRICING_CARD_SCHEMA = "lovstudio/pricing-card/v1"
      MANIFEST_SCHEMA = "skill-manifest/v1"
      RUNTIME_VERSION = "skill-runtime/v1"
      PROFILE_SCHEMA = "user-profile/v1"
      CONTENT_CLASSES = {
          "authored-prose",
          "microcopy",
          "verbatim",
          "deterministic-output",
      }
      
      
      class ValidationFailure(Exception):
          """Raised when source metadata cannot be parsed."""
      
      
      def read_text(path: Path) -> str:
          return path.read_text(encoding="utf-8")
      
      
      def compact_text(value: Any) -> str:
          return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else ""
      
      
      def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]:
          text = read_text(path)
          if not text.startswith("---\n"):
              raise ValidationFailure(f"{path}: missing YAML frontmatter")
          marker = text.find("\n---\n", 4)
          if marker < 0:
              raise ValidationFailure(f"{path}: frontmatter is not closed")
          try:
              data = yaml.safe_load(text[4:marker])
          except yaml.YAMLError as exc:
              raise ValidationFailure(
                  f"{path}: standard YAML parser rejected frontmatter: {exc}"
              ) from exc
          if not isinstance(data, dict):
              raise ValidationFailure(f"{path}: frontmatter must be a mapping")
          return data, text[marker + 5 :]
      
      
      def iter_files(root: Path) -> Iterable[Path]:
          for path in root.rglob("*"):
              if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
                  continue
              if path.is_file():
                  yield path
      
      
      def is_relative_to(path: Path, parent: Path) -> bool:
          try:
              path.relative_to(parent)
              return True
          except ValueError:
              return False
      
      
      def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None:
          try:
              data, body = split_frontmatter(path)
          except ValidationFailure as exc:
              errors.append(str(exc))
              return None
      
          unexpected = sorted(set(data) - FRONTMATTER_KEYS)
          if unexpected:
              errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}")
      
          name = compact_text(data.get("name"))
          if not NAME_RE.fullmatch(name) or len(name) > 64:
              errors.append(f"{path}: name must be kebab-case and at most 64 characters")
      
          description = compact_text(data.get("description"))
          if not 50 <= len(description) <= 200:
              errors.append(
                  f"{path}: description must contain 50-200 characters "
                  f"(found {len(description)})"
              )
      
          compatibility = compact_text(data.get("compatibility"))
          if not compatibility:
              errors.append(f"{path}: compatibility is required at the top level")
      
          depends_on = data.get("depends_on", [])
          if isinstance(depends_on, str):
              depends_on = [depends_on]
          if not isinstance(depends_on, list) or not all(
              isinstance(item, str) and NAME_RE.fullmatch(item) for item in depends_on
          ):
              errors.append(f"{path}: depends_on must be a list of exact Skill names")
      
          metadata = data.get("metadata")
          if not isinstance(metadata, dict):
              errors.append(f"{path}: metadata must be a mapping")
          else:
              if not compact_text(metadata.get("author")):
                  errors.append(f"{path}: metadata.author is required")
              if not SEMVER_RE.fullmatch(compact_text(metadata.get("version"))):
                  errors.append(f"{path}: metadata.version must use SemVer")
              tags = metadata.get("tags")
              if not isinstance(tags, list) or not tags or not all(
                  isinstance(tag, str) and tag.strip() for tag in tags
              ):
                  errors.append(f"{path}: metadata.tags must be a non-empty list")
              if "card_standard" in metadata and metadata.get("card_standard") != CARD_STANDARD:
                  errors.append(f"{path}: metadata.card_standard must be {CARD_STANDARD}")
              content_class = compact_text(metadata.get("content_class"))
              if content_class and content_class not in CONTENT_CLASSES:
                  errors.append(
                      f"{path}: metadata.content_class must be one of "
                      f"{', '.join(sorted(CONTENT_CLASSES))}"
                  )
              if content_class in {"authored-prose", "microcopy"} and (
                  not isinstance(depends_on, list)
                  or "lov-branding-consistency" not in depends_on
              ):
                  errors.append(
                      f"{path}: {content_class} requires lov-branding-consistency"
                  )
              if content_class == "authored-prose":
                  authorship_reference = path.parent / "references" / "authorship-integrity.md"
                  if not authorship_reference.is_file():
                      errors.append(
                          f"{authorship_reference}: authored-prose contract is required"
                      )
                  if "references/authorship-integrity.md" not in body:
                      errors.append(
                          f"{path}: authored-prose must route to references/authorship-integrity.md"
                      )
      
          trigger_block = re.search(
              r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body
          )
          if not trigger_block:
              errors.append(f"{path}: add an explicit '## Triggers' section")
          else:
              block = trigger_block.group(1)
              if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3:
                  errors.append(f"{path}: add two activation examples and one non-trigger")
              if not re.search(r"[\u3400-\u9fff]", block):
                  errors.append(f"{path}: add a concrete Chinese trigger phrase")
              if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block):
                  errors.append(f"{path}: add a concrete English trigger phrase")
          if not re.search(
              r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$",
              body,
          ):
              errors.append(f"{path}: add explicit non-trigger conditions")
          if len(read_text(path).splitlines()) >= 500:
              errors.append(f"{path}: keep SKILL.md below 500 lines")
          if not body.strip():
              errors.append(f"{path}: body is empty")
          return data
      
      
      def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None:
          try:
              data = yaml.safe_load(read_text(path))
          except yaml.YAMLError as exc:
              errors.append(f"{path}: standard YAML parser rejected file: {exc}")
              return None
          if not isinstance(data, dict):
              errors.append(f"{path}: expected a YAML mapping")
              return None
          return data
      
      
      def validate_runtime_manifest(
          skill_root: Path, expected_skill_id: str, errors: list[str]
      ) -> None:
          manifest_path = skill_root / "skill.yaml"
          if not manifest_path.is_file():
              errors.append(f"{manifest_path}: user-profile runtime manifest is required")
              return
          data = load_yaml(manifest_path, errors)
          if data is None:
              return
          if data.get("schema") != MANIFEST_SCHEMA:
              errors.append(f"{manifest_path}: schema must be {MANIFEST_SCHEMA}")
          if data.get("id") != expected_skill_id:
              errors.append(f"{manifest_path}: id must match {expected_skill_id}")
          version = compact_text(data.get("version"))
          if not SEMVER_RE.fullmatch(version):
              errors.append(f"{manifest_path}: version must use SemVer")
          if data.get("runtime") != RUNTIME_VERSION:
              errors.append(f"{manifest_path}: runtime must be {RUNTIME_VERSION}")
      
          context = data.get("context")
          if not isinstance(context, dict):
              errors.append(f"{manifest_path}: context must be a mapping")
              return
          profile = context.get("profile")
          preferences = context.get("preferences")
          interaction = context.get("interaction")
          if not isinstance(profile, dict):
              errors.append(f"{manifest_path}: context.profile must be a mapping")
          else:
              if profile.get("schema") != PROFILE_SCHEMA:
                  errors.append(f"{manifest_path}: context.profile.schema must be {PROFILE_SCHEMA}")
              if profile.get("source") != "shared-profile":
                  errors.append(f"{manifest_path}: context.profile.source must be shared-profile")
              reads = profile.get("read")
              if not isinstance(reads, list) or not reads or not all(
                  isinstance(item, str) and item.strip() for item in reads
              ):
                  errors.append(f"{manifest_path}: context.profile.read must be a non-empty list")
              persist = profile.get("persist")
              if not isinstance(persist, dict):
                  errors.append(f"{manifest_path}: context.profile.persist must be a mapping")
              else:
                  if persist.get("enabled") is not True:
                      errors.append(f"{manifest_path}: profile persistence must be enabled")
                  if persist.get("namespace") != f"skills.{expected_skill_id}":
                      errors.append(f"{manifest_path}: persist.namespace must target skills.{expected_skill_id}")
                  if persist.get("records_path") != f"skills.{expected_skill_id}.records":
                      errors.append(f"{manifest_path}: persist.records_path must target Skill records")
                  if persist.get("write_policy") != "direct-user-statement":
                      errors.append(f"{manifest_path}: persist.write_policy must be direct-user-statement")
                  if persist.get("atomic") is not True:
                      errors.append(f"{manifest_path}: profile persistence must be atomic")
              fields = profile.get("fields")
              if not isinstance(fields, list) or not fields:
                  errors.append(f"{manifest_path}: context.profile.fields must be a non-empty list")
              else:
                  validate_manifest_fields(manifest_path, "profile", fields, errors)
          if not isinstance(preferences, dict):
              errors.append(f"{manifest_path}: context.preferences must be a mapping")
          else:
              fields = preferences.get("fields")
              if not isinstance(fields, list):
                  errors.append(f"{manifest_path}: context.preferences.fields must be a list")
              else:
                  validate_manifest_fields(manifest_path, "preferences", fields, errors)
          if not isinstance(interaction, dict):
              errors.append(f"{manifest_path}: context.interaction must be a mapping")
          else:
              if not isinstance(interaction.get("ask_missing"), bool):
                  errors.append(f"{manifest_path}: interaction.ask_missing must be boolean")
              max_questions = interaction.get("max_questions")
              if not isinstance(max_questions, int) or isinstance(max_questions, bool) or not 1 <= max_questions <= 3:
                  errors.append(f"{manifest_path}: interaction.max_questions must be 1-3")
      
      
      def validate_manifest_fields(
          manifest_path: Path, source_name: str, fields: list[Any], errors: list[str]
      ) -> None:
          for index, field in enumerate(fields):
              label = f"{manifest_path}: context.{source_name}.fields[{index}]"
              if not isinstance(field, dict):
                  errors.append(f"{label} must be a mapping")
                  continue
              if not compact_text(field.get("path")):
                  errors.append(f"{label}.path is required")
              if not isinstance(field.get("required"), bool):
                  errors.append(f"{label}.required must be boolean")
              if not compact_text(field.get("question")):
                  errors.append(f"{label}.question is required")
              aliases = field.get("aliases", [])
              if isinstance(aliases, str):
                  aliases = [aliases]
              if not isinstance(aliases, list) or not all(
                  isinstance(alias, str) and alias.strip() for alias in aliases
              ):
                  errors.append(f"{label}.aliases must be a list of paths")
      
      
      def has_content(value: Any) -> bool:
          if isinstance(value, str):
              return bool(value.strip())
          if isinstance(value, list):
              return any(has_content(item) for item in value)
          if isinstance(value, dict):
              return any(has_content(item) for item in value.values())
          return value is not None
      
      
      def contains_placeholder(value: Any) -> bool:
          if isinstance(value, str):
              return bool(re.search(r"\bTODO\b|\{[^}]+\}", value, re.I))
          if isinstance(value, list):
              return any(contains_placeholder(item) for item in value)
          if isinstance(value, dict):
              return any(contains_placeholder(item) for item in value.values())
          return False
      
      
      def validate_card_bundle(skill_root: Path, errors: list[str]) -> None:
          card_path = skill_root / "skill-card.yaml"
          card_doc_path = skill_root / "skill-card.md"
          cases_path = skill_root / "cases" / "cases.json"
          pricing_path = skill_root / "pricing-card.yaml"
      
          for path in (card_path, card_doc_path, cases_path, pricing_path):
              if not path.is_file():
                  errors.append(f"{path}: required Skill trust-bundle file is missing")
      
          card = load_yaml(card_path, errors) if card_path.is_file() else None
          if card is not None:
              if card.get("schema") != CARD_STANDARD:
                  errors.append(f"{card_path}: schema must be {CARD_STANDARD}")
              required = (
                  "description", "owner", "license", "use_case", "deployment",
                  "requirements", "risks", "references", "output", "version",
                  "ethical_considerations", "dimensions", "pricing", "distribution",
              )
              for key in required:
                  if key not in card or not has_content(card.get(key)):
                      errors.append(f"{card_path}: required field '{key}' is missing or empty")
              dimensions = card.get("dimensions")
              if not isinstance(dimensions, list) or len(dimensions) < 3:
                  errors.append(f"{card_path}: dimensions must contain at least three named dimensions")
              else:
                  ids: set[str] = set()
                  for index, dimension in enumerate(dimensions):
                      label = f"{card_path}: dimensions[{index}]"
                      if not isinstance(dimension, dict):
                          errors.append(f"{label}: expected a mapping")
                          continue
                      dimension_id = compact_text(dimension.get("id"))
                      if not dimension_id or dimension_id in ids:
                          errors.append(f"{label}: id is required and must be unique")
                      ids.add(dimension_id)
                      for key in ("label", "description", "evidence"):
                          if not compact_text(dimension.get(key)):
                              errors.append(f"{label}: '{key}' is required")
              risks = card.get("risks")
              if not isinstance(risks, list) or not risks:
                  errors.append(f"{card_path}: risks must contain at least one risk and mitigation")
              else:
                  for index, risk in enumerate(risks):
                      if not isinstance(risk, dict) or not compact_text(risk.get("risk")) or not compact_text(risk.get("mitigation")):
                          errors.append(f"{card_path}: risks[{index}] needs risk and mitigation")
              distribution = card.get("distribution")
              if not isinstance(distribution, dict) or not isinstance(distribution.get("paid"), list) or not isinstance(distribution.get("free"), list):
                  errors.append(f"{card_path}: distribution must declare paid and free lists")
              if contains_placeholder(card):
                  errors.append(f"{card_path}: replace unresolved TODO or template placeholders")
      
          if card_doc_path.is_file():
              card_doc = read_text(card_doc_path)
              required_headings = (
                  "Description", "Owner", "License", "Use Case", "Deployment Geography",
                  "Requirements", "Known Risks", "References", "Skill Output",
                  "Skill Version", "Ethical Considerations", "User Cases",
                  "Dimension Map", "Pricing Basis", "Distribution",
              )
              for heading in required_headings:
                  if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", card_doc):
                      errors.append(f"{card_doc_path}: add the '{heading}' section")
              if re.search(r"\bTODO\b|\{[^}]+\}", card_doc, re.I):
                  errors.append(f"{card_doc_path}: replace unresolved TODO or template placeholders")
      
          if cases_path.is_file():
              try:
                  cases = json.loads(read_text(cases_path))
              except json.JSONDecodeError as exc:
                  errors.append(f"{cases_path}: invalid JSON: {exc}")
                  cases = []
              if not isinstance(cases, list) or not cases:
                  errors.append(f"{cases_path}: include at least one real user case")
              else:
                  for index, case in enumerate(cases):
                      label = f"{cases_path}: cases[{index}]"
                      if not isinstance(case, dict):
                          errors.append(f"{label}: expected a mapping")
                          continue
                      for key in ("title", "description", "input", "prompt", "output"):
                          if not has_content(case.get(key)):
                              errors.append(f"{label}: '{key}' is required")
                      if contains_placeholder(case):
                          errors.append(f"{label}: replace unresolved TODO or template placeholders")
                      for image_key in ("cover", "gallery"):
                          image_values = case.get(image_key, [])
                          if isinstance(image_values, str):
                              image_values = [image_values]
                          if not isinstance(image_values, list):
                              errors.append(f"{label}: '{image_key}' must be a string or list")
                              continue
                          for image in image_values:
                              if not isinstance(image, str) or not image.strip():
                                  errors.append(f"{label}: '{image_key}' contains an empty path")
                              elif not re.match(r"^(?:https?:|/|data:)", image) and not (skill_root / image).is_file():
                                  errors.append(f"{label}: case asset does not exist: {image}")
      
          pricing = load_yaml(pricing_path, errors) if pricing_path.is_file() else None
          if pricing is not None:
              if pricing.get("schema") != PRICING_CARD_SCHEMA:
                  errors.append(f"{pricing_path}: schema must be {PRICING_CARD_SCHEMA}")
              for key in ("model", "currency", "list_price_cny", "basis", "boundary", "review_trigger", "confidence"):
                  if key not in pricing or (key != "list_price_cny" and not has_content(pricing.get(key))):
                      errors.append(f"{pricing_path}: required field '{key}' is missing or empty")
              if contains_placeholder(pricing):
                  errors.append(f"{pricing_path}: replace unresolved TODO or template placeholders")
      
      
      def validate_composition_reference(skill_root: Path, errors: list[str]) -> None:
          path = skill_root / "references" / "skill-composition.md"
          if not path.is_file():
              errors.append(f"{path}: required Skill group composition record is missing")
              return
          text = read_text(path)
          required_headings = (
              "Nearby Skills Inspected",
              "Atomic Handoffs",
              "Overlap Decisions",
              "Composition Decision",
          )
          for heading in required_headings:
              if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", text):
                  errors.append(f"{path}: add the '{heading}' section")
          if re.search(r"\bTODO\b|\{[^}]+\}", text, re.I):
              errors.append(f"{path}: replace unresolved TODO or template placeholders")
      
      
      def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None:
          manifest = root / "kit.yaml"
          if not manifest.exists():
              return
          data = load_yaml(manifest, errors)
          if data is None:
              return
          modules = data.get("modules")
          if not isinstance(modules, list) or not modules:
              errors.append(f"{manifest}: modules must be a non-empty list")
              return
          module_ids: set[str] = set()
          for index, module in enumerate(modules):
              label = f"{manifest}: modules[{index}]"
              if not isinstance(module, dict):
                  errors.append(f"{label}: expected a mapping")
                  continue
              module_id = compact_text(module.get("id"))
              skill_name = compact_text(module.get("skill"))
              relative = compact_text(module.get("path"))
              if not module_id or module_id in module_ids:
                  errors.append(f"{label}: id is required and must be unique")
              module_ids.add(module_id)
              module_path = (root / relative).resolve()
              if (
                  not relative
                  or not is_relative_to(module_path, root.resolve())
                  or not (module_path / "SKILL.md").is_file()
              ):
                  errors.append(f"{label}: missing module at '{relative}/SKILL.md'")
              if skill_name not in skill_names:
                  errors.append(f"{label}: unresolved child skill '{skill_name}'")
          pipelines = data.get("pipelines")
          if not isinstance(pipelines, dict) or not pipelines:
              errors.append(f"{manifest}: pipelines must be a non-empty mapping")
              return
          for pipeline, sequence in pipelines.items():
              if not isinstance(sequence, list) or not sequence:
                  errors.append(f"{manifest}: pipeline '{pipeline}' must be a non-empty list")
                  continue
              missing = [str(item) for item in sequence if item not in module_ids]
              if missing:
                  errors.append(
                      f"{manifest}: pipeline '{pipeline}' has unknown modules: "
                      + ", ".join(missing)
                  )
      
      
      def validate_local_references(root: Path, errors: list[str]) -> None:
          for path in iter_files(root):
              if path.suffix.lower() != ".md":
                  continue
              text = read_text(path)
              for raw in MARKDOWN_LINK_RE.findall(text):
                  target = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0]
                  if (
                      not target
                      or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I)
                      or any(token in target for token in ("TODO", "{", "}"))
                  ):
                      continue
                  if not (path.parent / target).resolve().exists():
                      errors.append(f"{path}: broken local link '{target}'")
              skill_root = path.parent if path.name == "SKILL.md" else root
              for variable, target in SKILL_PATH_RE.findall(text):
                  if "TODO" in target:
                      continue
                  base = skill_root if variable == "SKILL_DIR" else root
                  resolved = (base / target.rstrip(".,;:)")).resolve()
                  if not is_relative_to(resolved, root.resolve()) or not resolved.exists():
                      errors.append(f"{path}: missing required resource '${variable}/{target}'")
      
      
      def validate_hygiene(root: Path, errors: list[str]) -> None:
          private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)")
          for path in root.rglob("*"):
              if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
                  continue
              if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES:
                  errors.append(f"{path}: generated/cache artifact must not ship")
          for path in iter_files(root):
              if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py":
                  continue
              text = read_text(path)
              if private_path.search(text):
                  errors.append(f"{path}: contains a private absolute user path")
              if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text):
                  errors.append(f"{path}: unresolved TODO placeholder")
          for relative in ("workbuddy", "scripts/build_workbuddy.py"):
              if (root / relative).exists():
                  errors.append(
                      f"{root / relative}: platform distribution artifacts belong to skill-publish"
                  )
      
      
      def validate_source(root: Path, errors: list[str]) -> None:
          root_skill = root / "SKILL.md"
          skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))]
          if not root_skill.is_file():
              errors.append(f"{root_skill}: file is required")
              return
          parsed: list[tuple[Path, dict[str, Any]]] = []
          for path in skill_files:
              data = validate_skill_file(path, errors)
              if data:
                  parsed.append((path, data))
          names = {compact_text(data.get("name")) for _, data in parsed}
          if len(names) != len(parsed):
              errors.append(f"{root}: every embedded Skill must have a unique name")
          for path, data in parsed:
              metadata = data.get("metadata")
              if isinstance(metadata, dict) and metadata.get("card_standard") == CARD_STANDARD:
                  validate_card_bundle(path.parent, errors)
              validate_composition_reference(path.parent, errors)
              validate_runtime_manifest(path.parent, compact_text(data.get("name")), errors)
          validate_kit(root, names, errors)
      
          readme = root / "README.md"
          if not readme.is_file():
              errors.append(f"{readme}: file is required")
          elif parsed:
              metadata = parsed[0][1].get("metadata")
              version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else ""
              if version and f"version-{version}-" not in read_text(readme):
                  errors.append(f"{readme}: version badge must match {version}")
      
          validate_hygiene(root, errors)
          validate_local_references(root, errors)
      
      
      def main() -> int:
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("path", type=Path, help="Local Skill source directory")
          args = parser.parse_args()
          root = args.path.expanduser().resolve()
          if not root.is_dir():
              print(f"ERROR: directory does not exist: {root}", file=sys.stderr)
              return 2
          errors: list[str] = []
          validate_source(root, errors)
          if errors:
              print(f"FAILED: {len(errors)} issue(s)")
              for error in errors:
                  print(f"- {error}")
              return 1
          print(f"PASSED: source validation ({root})")
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
  • CHANGELOG.md 1.1 KB
    # Changelog
    
    ## [8.7.1] - 2026-09-07
    
    - 将历史 slash command 升级为通用 Skill,补全自然语言触发、显式输入、共享 Profile 与验收边界。
    - 保留业务目的,修正宿主耦合和不安全的隐式动作;迁移案例与实际业务验收分别记录。
    
    
    All notable changes to this skill are documented here.
    Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: [SemVer](https://semver.org/)
    
    ## [8.6.0] - 2026-07-25
    
    ### Changed
    
    - Move regional and China mirror synchronization into a separately retryable post-CI workflow
    - Keep canonical publishing independent from mirror availability or upload duration
    - Require post-CI mirror jobs to download immutable assets from the published release tag
    
    ## [8.5.0] - 2026-05-08
    
    ### Added
    
    - Add hardened Tauri release signing and recovery guidance
    - Document Developer ID-only p12 export, notarization secrets, and macOS asset verification
    - Add safe release notes output, GitHub API retry polling, draft cleanup, and dirty-worktree safeguards
    - Preserve Bun package-manager workflows and add Windows no-bundle zip fallback
    
  • LICENSE 1 KB · in bundle
  • pricing-card.yaml 425 B
    schema: lovstudio/pricing-card/v1
    model: free_entry
    currency: CNY
    list_price_cny: 0
    basis: 轻量公开工具能力;当前证据主要是源码迁移与安装验证,采用免费入口积累业务结果。
    boundary: 交付 Skill 源码与使用合同,不含第三方服务费用、账户或人工代运营。
    review_trigger: 十次真实业务使用或依赖接口与维护成本明显变化后复评。
    confidence: low
    
  • README.md 1.1 KB
    # 自动发版 · Release Pilot
    
    ![Version](https://img.shields.io/badge/version-8.7.1-CC785C)
    
    配置或运行现有项目发布流水线,并回读产物与真实上线状态。
    
    ## 安装
    
    ```bash
    npx skills add lov-release-via-cicd -g -y
    ```
    
    ## 使用
    
    - 配置或运行现有项目发布流水线,并回读产物与真实上线状态。
    - Set up or run a verified CI/CD release.
    
    支持自然语言调用,具体执行步骤与边界见 [SKILL.md](SKILL.md)。分析、预览和实际修改分开处理。
    
    ## 配置与依赖
    
    读取 [共享 Profile](references/user-profile.md);项目路径、输出目录与品牌来自当前请求或用户配置。
    业务工具和目标系统依赖见工作流;公开文案使用 lov-branding-consistency 审校。
    
    ## 质量与案例
    
    ```bash
    python3 scripts/validate_skill.py .
    ```
    
    [案例](cases/cases.json) 记录真实迁移输入和输出,仅证明所声明的验收范围。
    [Skill Card](skill-card.md) 与 [定价依据](pricing-card.yaml) 说明使用边界;业务运行结果须逐次回读。
    
    ## License
    
    MIT
    
  • skill-card.md 1.1 KB
    # 自动发版 · Release Pilot · Skill Card
    
    ## Description
    
    配置或运行现有项目发布流水线,并回读产物与真实上线状态
    
    ## Owner
    
    contributors
    
    ## License
    
    MIT
    
    ## Use Case
    
    配置或运行现有项目发布流水线,并回读产物与真实上线状态
    
    ## Deployment Geography
    
    本地运行;外部系统仅按用户明确目标访问。
    
    ## Requirements
    
    参见 SKILL.md 的兼容性和工作流。
    
    ## Known Risks
    
    项目版本与依赖不同;结构验收不等于业务运行成功。
    
    ## References
    
    [工作流](SKILL.md) · [组合关系](references/skill-composition.md)
    
    ## Skill Output
    
    配置或运行现有项目发布流水线,并回读产物与真实上线状态
    
    ## Skill Version
    
    8.7.1
    
    ## Ethical Considerations
    
    不发布秘密,不伪造来源与验证结果。
    
    ## User Cases
    
    [迁移案例](cases/cases.json);此案例仅覆盖通用化合同。
    
    ## Dimension Map
    
    可移植性、配置隔离、可追溯性;未给未经测量的业务评分。
    
    ## Pricing Basis
    
    免费入口;[价值与边界](pricing-card.yaml)。
    
    ## Distribution
    
    GitHub 与 LovStudio:prepared;WorkBuddy 与 SkillPay:未选择。
    
  • skill-card.yaml 1.6 KB
    schema: lovstudio/skill-card/v1
    description: 配置或运行现有项目发布流水线,并回读产物与真实上线状态
    owner: contributors
    license: MIT
    use_case: 配置或运行现有项目发布流水线,并回读产物与真实上线状态
    deployment: 本地 Agent 宿主,业务系统按任务指定
    requirements: 可读取文件的 Agent;业务依赖见 SKILL.md
    risks:
    - risk: 不同项目与工具版本存在差异,迁移验证不能替代业务运行验证。
      mitigation: 执行前发现实际环境,按工作流回读结果。
    references:
    - SKILL.md
    - cases/cases.json
    - references/skill-composition.md
    output: 配置或运行现有项目发布流水线,并回读产物与真实上线状态
    version: 8.7.1
    ethical_considerations: 保留来源、用户授权和秘密边界,不虚构案例或外部完成状态。
    dimensions:
    - id: portability
      label: 宿主可移植性
      description: 显式输入与实际宿主能力
      evidence: SKILL.md 的 Triggers 和 Execution boundary
      score: null
    - id: context
      label: 配置隔离
      description: 用户配置与公开源分离
      evidence: skill.yaml 与 Profile 合同
      score: null
    - id: traceability
      label: 结果可追溯
      description: 原始输入摘要与迁移结果
      evidence: cases/cases.json;尚无业务效果评分
      score: null
    pricing:
      model: free_entry
      basis: pricing-card.yaml
    distribution:
      free:
      - channel: github
        status: prepared
      - channel: lovstudio
        status: prepared
      paid:
      - channel: workbuddy
        status: not_selected
      - channel: skillpay
        status: not_selected
    
  • SKILL.md 5.2 KB
    ---
    name: lov-release-via-cicd
    license: MIT
    compatibility: 'Requires Git, GitHub CLI (`gh`), the project''s package manager, and
      platform build tools. Tauri macOS signing additionally requires Apple Developer
      ID certificate and notarization credentials.
    
      '
    description: 配置或运行现有项目发布流水线,并回读产物与真实上线状态。支持明确输入与结果回读。Use to set up or run a verified
      CI/CD release.
    depends_on:
    - lov-branding-consistency
    metadata:
      author: contributors
      version: 8.7.1
      tags:
      - release
      - cicd
      - github-actions
      - tauri
      - macos-signing
      - notarization
      - changesets
      content_class: deterministic-output
      card_standard: lovstudio/skill-card/v1
    ---
    
    # 自动发版 · Release Pilot
    
    配置或运行现有项目发布流水线,并回读产物与真实上线状态。
    
    ## Triggers
    
    ### Activate when
    
    - “配置或运行现有项目发布流水线,并回读产物与真实上线状态。”
    - “Set up or run a verified CI/CD release.”
    
    ### Do not activate when
    
    - 只是查询本 Skill 的说明,或请求与上述结果无关的任务;不执行实际业务操作。
    - 用户仅要预览或审查时,不进入修改、提交或发布分支。
    
    ## Execution boundary
    
    自然语言请求即可触发;无需旧 slash 路径、参数插值或指定助手。明确解析当前请求中的
    项目、目标文件、选项与输出位置;用当前宿主实际提供的文件、搜索、CLI 和浏览器能力。
    项目依赖版本与外部 API 在执行时核实,不能假设示例是现行配置。随包脚本从 Skill 根解析,
    业务文件从目标项目根解析。先读当前状态,保护已有未提交内容与其他任务的暂存区。
    分析、预览请求保持只读;修改、提交、推送、部署和发布各依当前请求的明确范围执行。
    不绕过保护、自动发送消息、强制结束用户进程或抢前台。失败保留可诊断原始错误。
    
    ## Workflow
    
    1. 区分 setup、publish、setup+publish、local、ci、ci-auto 与 status;仅审计或配置不授权发布。读取项目语言、包管理器、版本、CHANGELOG、分支、remote、保护规则及现有 workflows。
    
    2. 保留当前发布方式,配置缺口先按项目事实修复。迁移 semantic-release 到 changesets 是独立行为,只有用户要求时实施;不无条件安装 changesets。
    
    3. 发布前审阅目标差异和已暂存文件,只提交本次发布涉及文件。脏工作区不得自动 git add -A,不能强制切主分支、自动吞掉提交失败或覆盖其他任务。
    
    4. 默认按当前版本补丁递增,0.x 保持 0.x;破坏性升级按用户指定处理。同步所有实际版本源、lockfile 和 CHANGELOG,发布说明从已核验变更生成,不虚构历史。
    
    5. 分支发布遵守保护与 PR 流程,必要时使用独立工作树;不能绕过评审、hooks、签名或非快进保护。已发 tag 不改写,缺漏用补丁版修复。
    
    6. Node/monorepo 依据配置生成 changeset 并运行 version;Shell 或其他项目按既有版本文件与 tag 规则处理。推 tag 和触发 workflow 只在发布授权的目标范围内执行。
    
    7. CI 参数、action 与签名配置按当前官方和项目既有实现核验。桌面或 Tauri 项目完整读取 references/tauri-release-workflow.md;其他项目读取 references/general-release-playbooks.md。
    
    8. 等待已触发 run 的最终状态,记录具体 run ID、commit、tag 和失败日志;较长过程使用宿主等待或监控工具,不以排队视为完成。
    
    9. 回读 Release 与附件,桌面验证签名、公证、安装包内 App 和更新链;需要区域镜像时只在主发布和验证通过后同步,不能让镜像失败掩盖主产物状态。
    
    10. 只在明确授权时向相关 issue 发消息或关闭;发布本身不自动授权对外评论。报告配置、构建、发布、官网/商店与安装验证各层真实结果。
    
    ## Composition
    
    执行前读取 [能力组合](references/skill-composition.md),按明确制品交接相邻能力。
    
    ## Runtime context (shared)
    
    运行前读取本包 `skill.yaml` 与 [Profile 合同](references/user-profile.md)。优先级为当前请求、
    项目上下文、本 Skill records、共享 preferences、brand/user Profile、安全默认值。
    只读取声明字段;没有专用运行时的宿主可使用 `scripts/profile_store.py` 读取共享 Profile。
    配置缺失只问影响结果的一个问题。用户明确要求长期保存的值通过该脚本原子写入,
    报告实际路径;不保存推断、凭据或其他任务的资料。
    
    ## 通用反馈闭环
    
    用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:
    
    1. 先判断意见是 `task-specific`(仅本次)还是 `reusable`(可跨任务复用)。
    2. `task-specific` 只修改当前任务,不改 Skill。
    3. `reusable` 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。
    4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
    5. `reusable` 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
    
  • skill.yaml 2.2 KB
    schema: skill-manifest/v1
    id: lov-release-via-cicd
    version: 8.7.1
    runtime: skill-runtime/v1
    context:
      profile:
        schema: user-profile/v1
        source: shared-profile
        read:
        - user
        - brand
        - workspace
        - preferences
        - skills.lov-release-via-cicd
        persist:
          enabled: true
          namespace: skills.lov-release-via-cicd
          records_path: skills.lov-release-via-cicd.records
          write_policy: direct-user-statement
          atomic: true
        fields:
        - path: user.name
          aliases:
          - identity.name
          required: false
          question: 如果本次输出需要用户身份,请提供名称。
        - path: user.language
          required: false
          question: 希望使用哪种语言输出?
        - path: user.timezone
          required: false
          question: 需要使用哪个时区处理日期和时间?
        - path: brand.name
          aliases:
          - identity.name
          required: false
          question: 如果本次输出需要品牌身份,请提供品牌名称。
        - path: brand.site
          required: false
          question: 如果需要品牌官网,请提供地址。
        - path: brand.tone
          required: false
          question: 如果已有品牌语气或审美关键词,请提供它们。
        - path: identity.name
          required: true
          question: 如果本次输出需要品牌身份,请提供品牌名称。
        - path: identity.logo
          required: false
          question: 如果需要使用品牌 Logo,请提供 Logo 地址或文件路径。
        - path: workspace.project_root
          required: false
          question: 目标项目路径是什么?
        - path: workspace.output_dir
          required: false
          question: 输出保存在哪里?
        - path: workspace.knowledge_root
          required: false
          question: 本次知识库路径是什么?
        - path: skills.lov-release-via-cicd.profile
          required: false
          question: 本 Skill 有哪些明确配置?
      preferences:
        namespace: lov_release_via_cicd
        fields:
        - path: user.language
          required: false
          question: 希望使用哪种语言输出?
        - path: user.timezone
          required: false
          question: 需要使用哪个时区处理日期和时间?
      interaction:
        ask_missing: true
        max_questions: 1
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related