mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Add a step that signs main.js, manifest.json, and styles.css with the workflow's OIDC identity and publishes the attestation to Sigstore's public transparency log via actions/attest-build-provenance@v2. Why: Obsidian's plugin review tooling flags release assets without an attestation as a soft warning. Attestations cryptographically prove the asset was built by this workflow on this commit, defending against an attacker who gains release-asset-replace permissions and silently swaps in a compromised main.js. Verification post-release: gh attestation verify main.js --owner logancyang --repo logancyang/obsidian-copilot Placement: the new step runs AFTER the prerelease manifest swap and BEFORE gh release create. That way the attested manifest.json is exactly the bytes that get uploaded — for stable releases that's the committed manifest.json; for prereleases it's the in-runner copy of manifest-beta.json. Permissions: adds attestations: write and id-token: write to the job. The id-token: write permission is what allows the workflow to request an OIDC token from GitHub; the attest action uses that token as proof of identity when signing. No secrets to manage. Free for public repos. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
11 KiB
YAML
216 lines
11 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
|
|
attestations: write # required to publish artifact attestations
|
|
id-token: write # required for the workflow's OIDC token (used to sign attestations)
|
|
|
|
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: Generate build provenance attestation ──────────────────────
|
|
# Cryptographically signs main.js / manifest.json / styles.css with the
|
|
# workflow's OIDC identity and publishes the attestation to Sigstore's
|
|
# public transparency log. Anyone can later verify a downloaded asset
|
|
# was actually built by this workflow on this commit with:
|
|
# gh attestation verify main.js --owner logancyang --repo logancyang/obsidian-copilot
|
|
#
|
|
# Runs AFTER the prerelease manifest swap so the attested manifest.json
|
|
# exactly matches the file uploaded to the GitHub Release.
|
|
- name: Generate artifact attestation
|
|
if: steps.semver.outputs.is_release == 'true' && steps.check_existing.outputs.exists != 'true'
|
|
uses: actions/attest-build-provenance@v2
|
|
with:
|
|
subject-path: |
|
|
main.js
|
|
manifest.json
|
|
styles.css
|
|
|
|
# ── Step 11: 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
|