logancyang_obsidian-copilot/.github/workflows/release.yml
Logan Yang 28cfff08ef
chore(release): make prerelease tooling stop poisoning master's manifest.json (#2429)
* chore(release): make prerelease tooling stop poisoning master's manifest.json

Background: Obsidian's community plugin store reads manifest.json from master
to decide which GitHub Release to serve installers. The prerelease agent's
npm version bump was rewriting manifest.json with a prerelease version, which
broke plugin installs until hotfix #2428 reverted master's manifest.json to
the stable version.

This PR fixes the underlying tooling so it can't recur.

Changes:

- version-bump.mjs now branches on whether npm_package_version is a prerelease
  (contains a hyphen). Stable: writes to manifest.json + versions.json. Prerelease:
  writes to manifest-beta.json + versions.json, leaving manifest.json untouched.
  Seeds manifest-beta.json from manifest.json when it doesn't exist yet.
  On a stable bump that finds a stale manifest-beta.json, removes it (git rm)
  because the new stable supersedes the in-flight beta. Stages the right files
  itself; package.json no longer needs the explicit git-add step.

- package.json: simplifies the "version" lifecycle script to just
  "node version-bump.mjs" since the script now stages files itself.

- release.yml:
  * "Verify version matches manifest" step now selects the file based on
    is_prerelease (manifest.json for stable, manifest-beta.json for prerelease).
    For stable runs nothing changes.
  * Adds a "Prepare release-asset manifest" step before the release upload that
    copies manifest-beta.json over manifest.json IN THE RUNNER only when the
    release is a prerelease. This makes the uploaded manifest.json asset carry
    the prerelease version so testers sideloading the assets get a consistent
    manifest. The committed master manifest.json is never touched by the
    workflow (the workflow doesn't push back).

- .claude/agents/release.md: adds a Step 0 pre-flight assertion that master's
  manifest.json.version equals the latest non-prerelease GitHub Release tag.
  Catches drift loudly before doing any work. Notes the manifest-beta.json
  auto-removal in the rules.

- .claude/agents/prerelease.md: documents the manifest.json / manifest-beta.json
  split. Adds the same Step 0 drift assertion. Updates the git add step to
  stage manifest-beta.json instead of manifest.json. Adds an explicit guard
  rule that manifest.json must not change during a prerelease bump.

Verified locally by exercising version-bump.mjs in a throwaway repo:
- Stable 3.2.8 -> 3.2.9: writes manifest.json + versions.json, no beta side
- Prerelease 3.2.8 -> 3.2.9-beta.0: creates manifest-beta.json, manifest.json untouched
- Prerelease iteration 3.2.9-beta.0 -> 3.2.9-beta.1: updates manifest-beta.json,
  manifest.json untouched
- Stable supersedes beta 3.2.9-beta.1 -> 3.2.9: writes manifest.json,
  git-rm's manifest-beta.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release): address Codex review on PR #2429

1. release.yml: when verifying a prerelease, also assert that the committed
   manifest.json on the merge commit still equals the latest non-prerelease
   GitHub Release tag. This catches an old-style version bump or hand edit
   that accidentally modified master's manifest.json — without this guard,
   the verify step only inspected manifest-beta.json so a poisoned
   manifest.json could merge unnoticed and break the Obsidian plugin store.

2. version-bump.mjs: don't fail stable bumps when manifest-beta.json exists
   on disk but is untracked (or has local modifications). Check git
   trackedness first; use `git rm -f` only when tracked, fall back to a
   plain unlink for working-tree-only files. Logs which path it took.

Verified both scenarios in a throwaway repo:
- Untracked manifest-beta.json + stable bump: unlinked, no git rm error
- Tracked manifest-beta.json + stable bump: staged deletion via git rm -f

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release): use /releases/latest API for stable-drift guard

gh release list defaults to 30 results, so a cluster of 30+ prereleases
since the last stable would make the prerelease drift guard return empty
and fail valid prerelease publishes. Switch to GitHub's /releases/latest
endpoint instead, which by design returns only the most-recent
non-prerelease, non-draft release in a single call regardless of how
many prereleases have accumulated.

Applied the same fix to the Step 0 drift check in both the release agent
and prerelease agent doc for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:53:48 -07:00

196 lines
9.6 KiB
YAML

# Release workflow: triggered when a PR targeting master is merged and its title
# is a semver string. Two formats are accepted:
# - Stable release: "X.Y.Z" (e.g. "3.2.3")
# - Prerelease: "X.Y.Z-<tag>.<N>" (e.g. "3.2.9-beta.1", "3.3.0-rc.0")
# Prerelease titles publish a GitHub Release marked as a prerelease.
# The PR title becomes the release tag and title; the PR body becomes the release notes.
#
# Non-semver PR titles (feature PRs, bug fixes, etc.) are silently ignored —
# the workflow runs but exits early without creating a release.
name: Release
on:
pull_request:
types: [closed]
branches:
- master
jobs:
release:
# Only proceed when the PR was actually merged (not just closed/abandoned).
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write # required to create GitHub releases and tags
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
# ── Step 1: Validate that the PR title is a semver (stable or prerelease) ──
# Exits without error when the title is not semver so non-release PRs
# pass silently. Sets outputs.version, outputs.is_release, and
# outputs.is_prerelease for downstream steps.
- name: Validate semver PR title
id: semver
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
if echo "$PR_TITLE" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "PR title '$PR_TITLE' is a stable semver — proceeding with release."
echo "version=$PR_TITLE" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
elif echo "$PR_TITLE" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z-]+\.[0-9]+$'; then
echo "PR title '$PR_TITLE' is a prerelease semver — proceeding with prerelease."
echo "version=$PR_TITLE" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "PR title '$PR_TITLE' is not semver (X.Y.Z or X.Y.Z-tag.N) — skipping release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
fi
# ── Step 2: Checkout the merge commit on master ─────────────────────────
- name: Checkout
if: steps.semver.outputs.is_release == 'true'
uses: actions/checkout@v4
with:
# Pin to the exact merge commit SHA so concurrent PRs merging to
# master don't cause this workflow to build the wrong code.
ref: ${{ github.event.pull_request.merge_commit_sha }}
# ── Step 3: Verify PR title version matches the source-of-truth manifest ──
# For stable releases the source of truth is manifest.json (which the
# Obsidian plugin store reads). For prereleases the source of truth is
# manifest-beta.json (manifest.json must stay pinned at the latest stable
# so the plugin store keeps serving installs correctly).
#
# For prereleases we ALSO assert that manifest.json on the merge commit
# still matches the latest stable GitHub Release tag. This defends against
# an old-style version-bump or hand edit accidentally re-poisoning master.
- name: Verify version matches manifest
if: steps.semver.outputs.is_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.semver.outputs.version }}
IS_PRERELEASE: ${{ steps.semver.outputs.is_prerelease }}
run: |
if [ "$IS_PRERELEASE" = "true" ]; then
MANIFEST_FILE="manifest-beta.json"
# Guard: master's manifest.json must still equal the latest stable Release tag.
# Use /releases/latest which (per GitHub API) returns only the most-recent
# non-prerelease, non-draft release in one call — no pagination concerns
# even if many prereleases have accumulated since the last stable.
LATEST_STABLE=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" -q .tag_name 2>/dev/null || true)
STABLE_MANIFEST_VERSION=$(node -p "require('./manifest.json').version")
if [ -z "$LATEST_STABLE" ]; then
echo "Could not determine latest stable GitHub Release tag; aborting to avoid poisoning manifest.json"
exit 1
fi
if [ "$STABLE_MANIFEST_VERSION" != "$LATEST_STABLE" ]; then
echo "manifest.json on merge commit ('$STABLE_MANIFEST_VERSION') drifted from latest stable Release ('$LATEST_STABLE')."
echo "A prerelease PR must not modify manifest.json. Refusing to publish."
exit 1
fi
echo "manifest.json drift check passed: $STABLE_MANIFEST_VERSION matches latest stable."
else
MANIFEST_FILE="manifest.json"
fi
if [ ! -f "$MANIFEST_FILE" ]; then
echo "Expected $MANIFEST_FILE to exist for this release type but it does not."
exit 1
fi
MANIFEST_VERSION=$(node -p "require('./$MANIFEST_FILE').version")
if [ "$VERSION" != "$MANIFEST_VERSION" ]; then
echo "Version mismatch: PR title='$VERSION', $MANIFEST_FILE='$MANIFEST_VERSION'"
exit 1
fi
echo "Version check passed: $VERSION (verified against $MANIFEST_FILE)"
# ── Step 4: Set up Node.js ───────────────────────────────────────────────
- name: Setup Node.js 22.x
if: steps.semver.outputs.is_release == 'true'
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: npm
# ── Step 5: Install dependencies ────────────────────────────────────────
- name: Install dependencies
if: steps.semver.outputs.is_release == 'true'
run: npm ci
# ── Step 6: Build the plugin ─────────────────────────────────────────────
- name: Build
if: steps.semver.outputs.is_release == 'true'
run: npm run build
# ── Step 7: Write PR body to a file (avoids shell injection) ─────────────
# Using an environment variable to pass the PR body prevents special
# characters (backticks, quotes, dollar signs, etc.) from being
# interpreted by the shell.
- name: Write release notes to file
if: steps.semver.outputs.is_release == 'true'
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: printf '%s' "$PR_BODY" > /tmp/release-notes.md
# ── Step 8: Check if release already exists (idempotency guard) ─────────
- name: Check if release already exists
if: steps.semver.outputs.is_release == 'true'
id: check_existing
env:
VERSION: ${{ steps.semver.outputs.version }}
run: |
if gh release view "$VERSION" > /dev/null 2>&1; then
echo "::warning::Release $VERSION already exists — skipping."
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
# ── Step 9: Prepare release-asset manifest ──────────────────────────────
# For a prerelease, the manifest.json asset uploaded to the GitHub
# Release must carry the prerelease version (so testers who sideload
# the assets get a manifest matching what they downloaded). We swap
# manifest-beta.json into manifest.json's place IN THE RUNNER only —
# this never gets pushed back to master, so the committed manifest.json
# stays pinned to the latest stable.
- name: Prepare release-asset manifest
if: steps.semver.outputs.is_release == 'true' && steps.semver.outputs.is_prerelease == 'true'
run: |
cp manifest-beta.json manifest.json
echo "Release-asset manifest.json (prerelease):"
cat manifest.json
# ── Step 10: Create GitHub Release ───────────────────────────────────────
# --target pins the tag to the exact merge commit SHA so concurrent
# merges cannot cause the release tag to point at a different commit.
# When the PR title is a prerelease semver (X.Y.Z-tag.N), pass --prerelease
# so Obsidian's plugin browser does not offer it as a stable update.
# Artifacts: main.js, manifest.json, styles.css
- name: Create GitHub Release
if: steps.semver.outputs.is_release == 'true' && steps.check_existing.outputs.exists != 'true'
env:
VERSION: ${{ steps.semver.outputs.version }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
IS_PRERELEASE: ${{ steps.semver.outputs.is_prerelease }}
run: |
PRERELEASE_FLAG=""
if [ "$IS_PRERELEASE" = "true" ]; then
PRERELEASE_FLAG="--prerelease"
fi
gh release create "$VERSION" \
--target "$MERGE_SHA" \
--title "$VERSION" \
--notes-file /tmp/release-notes.md \
$PRERELEASE_FLAG \
main.js \
manifest.json \
styles.css