diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..55bcdae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +# Copyright (c) JFrog Ltd. 2026 +# +# Cuts a GitHub Release when a release marker is merged to main. +# Full flow and rationale: CONTRIBUTING.md#releasing +name: Release + +on: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + # Full history, so the tag check below can see existing tags. + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + # Subject line only, not the whole message. MSG goes through env rather than string + # interpolation, so a crafted commit subject can't inject shell. + - name: Detect release marker in commit subject + id: detect + env: + MSG: ${{ github.event.head_commit.message }} + run: | + SUBJECT=$(printf '%s\n' "$MSG" | head -1) + if printf '%s' "$SUBJECT" | grep -qE '\[(major|minor|patch)\]'; then + echo "triggered=true" >> "$GITHUB_OUTPUT" + else + echo "triggered=false" >> "$GITHUB_OUTPUT" + fi + + # The manifest is the only place the version lives. + - name: Read version from the plugin manifest + if: steps.detect.outputs.triggered == 'true' + id: version + run: | + set -euo pipefail + VERSION=$(jq -er '.version' .devin-plugin/plugin.json) + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::.devin-plugin/plugin.json version '$VERSION' is not X.Y.Z — refusing to release" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # A tag exists only if that version was released, so this catches a marker that was merged + # without a manifest bump. + - name: Refuse to re-release an existing version + if: steps.detect.outputs.triggered == 'true' + run: | + TAG="v${{ steps.version.outputs.version }}" + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error::$TAG already exists — bump .devin-plugin/plugin.json before merging a release marker" + exit 1 + fi + + - uses: actions/setup-node@v5 + if: steps.detect.outputs.triggered == 'true' + with: + node-version: "24" + + # validate.yml runs on this same push, but as an independent workflow that can't gate this + # one. Re-running its check here is what actually gates the release on it. + - name: Validate plugin layout before releasing + if: steps.detect.outputs.triggered == 'true' + run: node scripts/validate-devin-plugin.mjs + + # Tracked files at HEAD only, so nothing left on the runner can end up in the zip. + - name: Package release artifact + if: steps.detect.outputs.triggered == 'true' + run: git archive --format=zip --output=release.zip HEAD -- ':(exclude).github' + + # --target creates the tag as part of the release, so a failure can't leave an orphan tag. + - name: Create GitHub Release + if: steps.detect.outputs.triggered == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.version.outputs.version }}" \ + release.zip \ + --target "$GITHUB_SHA" \ + --title "Release v${{ steps.version.outputs.version }}" \ + --generate-notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8b9648..90e98d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,25 @@ This downloads the pinned upstream tarball and replaces the contents of `skills/ - [ ] If the skill tree changed: `pin` in `.github/scripts/sync-skills-vendor.json` matches the upstream tag the new tree was generated from. - [ ] Smoke-test: `devin plugins install . -y` and `devin plugins info jfrog` from the repo root. +## Releasing + +To cut a release: + +1. In your PR, bump `.version` in [`.devin-plugin/plugin.json`](.devin-plugin/plugin.json). That manifest is the only place the version lives. +2. Merge to `main` with `[major]`, `[minor]`, or `[patch]` in the commit **subject** - the first + line. A marker further down in the body is ignored on purpose: this repo squash-merges, and + GitHub pre-fills the squash body from the branch commits or the PR description, either of + which may quote a marker while only documenting it. + +The marker only decides *whether* to release; the version comes from the manifest either way, so the bump is reviewed in the PR that makes it. There is no bot push to `main`. Merging a marker without bumping the manifest fails the release rather than re-tagging a shipped version. + +The workflow reads the version from the manifest, refuses to continue if that version is already tagged, runs the same plugin-layout check as the `validate` PR workflow, packages the tracked files at `HEAD` (minus `.github/`) into `release.zip`, and creates the `vX.Y.Z` tag as part of publishing the GitHub Release. + +Two things to know before changing it: + +- Validation runs inside the release job. `validate.yml` triggers on the same push, but as an independent workflow, so it can be red while a release still goes out. Re-running its check in the release job is what actually gates the release on it. +- The tag is created by the release, not before it. `gh release create --target` does both in one API call, so a failed run can't leave a tag behind with no release attached to it. + ## Build order Releases follow a fixed sequence: