diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55bcdae..0f48fef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ # Copyright (c) JFrog Ltd. 2026 # -# Cuts a GitHub Release when a release marker is merged to main. +# Cuts a GitHub Release when the version file on main is newer than the latest tag. # Full flow and rationale: CONTRIBUTING.md#releasing name: Release @@ -24,68 +24,79 @@ jobs: 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 + if [[ ! "$VERSION" =~ ^[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' + # Fails the job on anything but a clean forward bump, so the steps after it need no + # condition of their own. + - name: Compare version against latest release tag + id: release_gate + env: + VERSION: ${{ steps.version.outputs.version }} 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" + set -euo pipefail + git fetch --tags origin + # git's globs can't anchor digits, so the shape is enforced with grep instead. grep + # exits 1 when nothing matches, which is the legitimate first-release case below. + LATEST=$(git tag -l | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1) || LATEST="" + if [ -z "$LATEST" ]; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "No prior release tag — v${VERSION} will be the first release" + exit 0 + fi + LATEST_VERSION="${LATEST#v}" + if [ "$VERSION" = "$LATEST_VERSION" ]; then + echo "::error::v${VERSION} was already released — bump .devin-plugin/plugin.json before merging" + exit 1 + fi + TAG_FOR_VERSION="v${VERSION}" + LOWEST=$(printf '%s\n%s\n' "$TAG_FOR_VERSION" "$LATEST" | sort -V | head -1) + if [ "$LOWEST" = "$TAG_FOR_VERSION" ]; then + echo "::error::.devin-plugin/plugin.json version ${VERSION} is older than the latest release ${LATEST} — check for an accidental revert" exit 1 fi + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "Version ${VERSION} is newer than ${LATEST} — proceeding with release" - 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 }} + VERSION: ${{ steps.version.outputs.version }} run: | - gh release create "v${{ steps.version.outputs.version }}" \ + gh release create "v${VERSION}" \ release.zip \ --target "$GITHUB_SHA" \ - --title "Release v${{ steps.version.outputs.version }}" \ + --title "Release v${VERSION}" \ --generate-notes + + # Only when the gate passed: a gate failure means v${VERSION} was already released by an + # earlier run, and deleting it here would destroy a shipped release. + - name: Roll back a partially published release + if: failure() && steps.release_gate.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: gh release delete "v${VERSION}" --yes --cleanup-tag || true diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a115d20..f625db4 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -15,6 +15,17 @@ jobs: steps: - uses: actions/checkout@v5 + # Catches a malformed version in review, where it is cheap to fix. The release workflow + # compares it against the latest tag; that comparison only makes sense on main. + - name: Check the manifest version is X.Y.Z + run: | + set -euo pipefail + VERSION=$(jq -er '.version' .devin-plugin/plugin.json) + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::.devin-plugin/plugin.json version '$VERSION' is not X.Y.Z" + exit 1 + fi + - name: Set up Node.js uses: actions/setup-node@v5 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90e98d2..17ec6b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ This downloads the pinned upstream tarball and replaces the contents of `skills/ ## Pre-release checklist - [ ] `node scripts/validate-devin-plugin.mjs` passes. -- [ ] Version bumped in [`.devin-plugin/plugin.json`](.devin-plugin/plugin.json) when the plugin changes. +- [ ] Version bumped in [`.devin-plugin/plugin.json`](.devin-plugin/plugin.json) — required on every PR merged to `main`, see [Releasing](#releasing). - [ ] No secrets, credentials, or files under `**/local-cache/` committed. - [ ] 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. @@ -53,19 +53,25 @@ This downloads the pinned upstream tarball and replaces the contents of `skills/ 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. +2. Merge to `main`. Every push to `main` compares the manifest version against the latest release tag: if the version is newer, a release proceeds; if it matches the latest tag, the workflow fails with a clear "already released" error; if it is older, it fails with a revert warning. -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 bump is reviewed in the PR that makes it. Merging without bumping the manifest fails the release rather than silently skipping or 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. +So once `v0.3.0` is tagged, **every** merge to `main` must carry a manifest bump. There is no opt-out: a merge that leaves `.version` at the released value turns the `Release` workflow red, and it stays red until a bump lands. Roll the bump into the PR itself rather than pushing a follow-up "bump only" commit. -Two things to know before changing it: +The workflow reads the version from the manifest, 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. + +Three 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. +- A run that fails *after* the version gate passed deletes the release and its tag on the way out, so the same version can be retried on the next push. That rollback is gated on the version gate having passed — otherwise a run that stopped at "already released" would delete the shipped release it was complaining about. + +### The v0.3.0 catch-up release + +The manifest reached `0.3.0` while the latest tag was still `v0.1.0`. Under the old release-marker mechanism a bump only shipped if a marker rode along with it, so `0.1.1`, `0.2.0`, and `0.3.0` were each bumped and never tagged. The first merge to `main` under the version-file mechanism therefore cuts a single catch-up release of `v0.3.0`, publishing the current tree under the version the manifest already carries. That merge is the one exception to the bump-every-merge rule above: the bump it releases already happened, in an earlier PR. The skipped versions are not back-filled — nothing was ever released as `0.1.1` or `0.2.0`, and `v0.3.0` ships the tree that supersedes both. + +Pull requests branched before this change are the other side of that gap. Any PR whose `.version` is `0.3.0` or lower would land a manifest at or behind the `v0.3.0` tag and turn the release red on merge. That covers the open skills-sync PRs [#6](https://github.com/jfrog/devin-plugin/pull/6), [#7](https://github.com/jfrog/devin-plugin/pull/7), and [#9](https://github.com/jfrog/devin-plugin/pull/9). Rebase each onto `main` and bump past `0.3.0`, or close it and regenerate the sync — #6 in particular re-syncs a bundle version that has already merged. Sort this out before merging them, not after the red run. ## Build order