From 9d259391e4f42d6916f13498c425285b10e45f1a Mon Sep 17 00:00:00 2001 From: diego-sigma <116198719+diego-sigma@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:32:19 -0400 Subject: [PATCH] Tighten the Validate and Release workflows Validate: refuse symlinks, keep no Git credentials after checkout, turn off the CLI's telemetry, and pin the CLI to 2.1.284. Release: publish only from main, with no manual trigger. Check that every plugin carries the same X.Y.Z version before using it in a tag. Fail instead of skipping when a release for the version already exists but came from a commit not on main, or when the plugins changed without a version bump. Scope the token to the steps that use it, store symlinks as links in archives, and run one release at a time. Both workflows pin actions/checkout to a commit. Dependabot keeps the pins current, holding new releases for 3 days. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 11 ++++++ .github/workflows/release.yml | 71 ++++++++++++++++++++++++++-------- .github/workflows/validate.yml | 26 +++++++++++-- 3 files changed, 88 insertions(+), 20 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6c424bc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + # Keeps the pinned action commits current. Each update arrives as a PR that + # Validate checks and a maintainer reviews. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + # Don't propose an action release until it's at least 3 days old. + cooldown: + default-days: 3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca55930..24d0366 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,47 +2,82 @@ name: Release # Merging a version bump is the approval to publish. When main carries a # version with no release yet, tag it and attach one .plugin archive per -# plugin. A push that doesn't change the version finds its release already +# plugin. A merge that doesn't change the version finds its release already # there and does nothing, and so does a main with no plugins in it yet. +# +# Only main publishes, and there is no manual trigger: a run from any other +# branch would publish that branch's unreviewed content, and tags here can't +# be moved or deleted. on: push: branches: [main] - workflow_dispatch: permissions: contents: write +# Two quick merges must not race on the same tag. +concurrency: + group: release + cancel-in-progress: false + jobs: release: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ github.token }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Every plugin carries the same version; the tag is built from it, so + # anything other than X.Y.Z is refused before it reaches a tag name. - name: Work out the version id: version run: | - if ! compgen -G 'plugins/claude/*/.claude-plugin/plugin.json' >/dev/null; then - echo "No plugins yet; nothing to release." - echo "none=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - version="$(python3 -c "import glob, json; print(json.load(open(sorted(glob.glob('plugins/claude/*/.claude-plugin/plugin.json'))[0]))['version'])")" - echo "tag=v${version}-beta" >> "$GITHUB_OUTPUT" - echo "slug=v${version//./-}-beta" >> "$GITHUB_OUTPUT" + python3 - <<'PY' + import glob, json, os, re + manifests = sorted(glob.glob("plugins/claude/*/.claude-plugin/plugin.json")) + with open(os.environ["GITHUB_OUTPUT"], "a") as out: + if not manifests: + print("No plugins yet; nothing to release.") + out.write("none=true\n") + raise SystemExit + versions = {json.load(open(m))["version"] for m in manifests} + if len(versions) != 1: + raise SystemExit(f"Plugins disagree on the version: {sorted(versions)}") + version = versions.pop() + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise SystemExit(f"Version {version!r} isn't X.Y.Z") + out.write(f"tag=v{version}-beta\n") + out.write(f"slug=v{version.replace('.', '-')}-beta\n") + PY + # An existing release is fine only if it was cut from main and the + # plugins haven't changed since. Otherwise this version was published + # from somewhere else, or the plugins changed without a version bump, + # and quietly skipping would leave main and the release disagreeing. - name: Check whether this version is already released id: released if: steps.version.outputs.none != 'true' env: + GH_TOKEN: ${{ github.token }} TAG: ${{ steps.version.outputs.tag }} run: | - if gh release view "$TAG" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else + if ! gh release view "$TAG" >/dev/null 2>&1; then echo "exists=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$TAG...$GITHUB_SHA" -q .status)" + if [ "$status" != "ahead" ] && [ "$status" != "identical" ]; then + echo "::error::$TAG was released from a commit that isn't on main." + exit 1 + fi + changed="$(gh api "repos/$GITHUB_REPOSITORY/compare/$TAG...$GITHUB_SHA" -q '[.files[]?.filename | select(startswith("plugins/") or startswith(".claude-plugin/"))] | length')" + if [ "$changed" != "0" ]; then + echo "::error::The plugins changed since $TAG without a version bump." + exit 1 fi + echo "exists=true" >> "$GITHUB_OUTPUT" - name: Archive each plugin if: steps.released.outputs.exists == 'false' @@ -52,11 +87,13 @@ jobs: mkdir dist for dir in plugins/claude/*/; do name="$(basename "$dir")" - (cd "$dir" && zip -qr "../../../dist/${name}-${SLUG}.plugin" . -x '*.DS_Store') + # -y stores a symlink as a link instead of following it. + (cd "$dir" && zip -qry "../../../dist/${name}-${SLUG}.plugin" . -x '*.DS_Store') done - name: Publish the release if: steps.released.outputs.exists == 'false' env: + GH_TOKEN: ${{ github.token }} TAG: ${{ steps.version.outputs.tag }} run: gh release create "$TAG" dist/*.plugin --target "$GITHUB_SHA" --title "$TAG" --prerelease --generate-notes diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1118166..0a5f737 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,6 +1,9 @@ name: Validate # Runs on every pull request, so a reviewer opens a diff that already passes. +# A green run means the plugins are well-formed, not that they're safe: it +# passes hooks, MCP server commands and bin/ files, so reviewers still read +# those. on: pull_request: push: @@ -12,14 +15,31 @@ permissions: jobs: validate: runs-on: ubuntu-latest + env: + # validate needs nothing from the network once the CLI is installed. + DISABLE_TELEMETRY: "1" + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # validate reads through a symlinked manifest and prints what it points + # at, and the release zip would follow links too. Nothing here is one. + - name: Refuse symlinks + run: | + links="$(find . -path ./.git -prune -o -type l -print)" + if [ -n "$links" ]; then + echo "$links" + echo "::error::Symlinks aren't allowed in this repository." + exit 1 + fi # Pinned rather than "latest": the CLI's rules change between releases, # and a moving target would turn a PR red with no change to the plugin. - # Bump it on purpose. + # Bump it on purpose, to a release at least 3 days old. - name: Install the Claude Code CLI - run: npm install -g @anthropic-ai/claude-code@2.1.280 + run: npm install -g @anthropic-ai/claude-code@2.1.284 - name: Validate the marketplace run: claude plugin validate --strict .