From 439c3e1b2cadc9f7a95530c7f33afecb1efb996e Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Wed, 15 Jul 2026 13:47:09 +0300 Subject: [PATCH 1/6] feat(AX-1837): add VERSION file, release workflow, and drift-prevention check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Seed VERSION at 0.1.1 (current .devin-plugin/plugin.json version) - release.yml: on push to main with [major/minor/patch], reads VERSION, creates vX.Y.Z git tag, publishes GitHub Release with repo zip - validate-version.yml: PR check — fails if VERSION != plugin.json version - CONTRIBUTING.md: add Releasing section documenting the new flow Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 55 ++++++++++++++++++++++++++ .github/workflows/validate-version.yml | 25 ++++++++++++ CONTRIBUTING.md | 9 +++++ VERSION | 1 + 4 files changed, 90 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/validate-version.yml create mode 100644 VERSION diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..468c804 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +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: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Detect release tag in commit message + id: detect + run: | + MSG="${{ github.event.head_commit.message }}" + if echo "$MSG" | grep -qE '\[(major|minor|patch)\]'; then + echo "triggered=true" >> "$GITHUB_OUTPUT" + else + echo "triggered=false" >> "$GITHUB_OUTPUT" + fi + + - name: Read version + if: steps.detect.outputs.triggered == 'true' + id: version + run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" + + - name: Create and push tag + if: steps.detect.outputs.triggered == 'true' + run: | + git tag "v${{ steps.version.outputs.version }}" + git push origin "v${{ steps.version.outputs.version }}" + + - name: Package release artifact + if: steps.detect.outputs.triggered == 'true' + run: zip -r release.zip . --exclude ".git/*" --exclude ".github/*" + + - 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 \ + --title "Release v${{ steps.version.outputs.version }}" \ + --generate-notes diff --git a/.github/workflows/validate-version.yml b/.github/workflows/validate-version.yml new file mode 100644 index 0000000..5f32229 --- /dev/null +++ b/.github/workflows/validate-version.yml @@ -0,0 +1,25 @@ +name: Validate version + +on: + pull_request: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check version consistency + run: | + VERSION=$(cat VERSION) + FAILED=0 + + PLUGIN_VERSION=$(jq -r '.version' .devin-plugin/plugin.json) + if [ "$VERSION" != "$PLUGIN_VERSION" ]; then + echo "::error::Version mismatch: VERSION=$VERSION but .devin-plugin/plugin.json.version=$PLUGIN_VERSION" + FAILED=1 + fi + + [ "$FAILED" -eq 0 ] && echo "All versions consistent: $VERSION" + exit $FAILED diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8b9648..2f8ab67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,15 @@ 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` and sync `.devin-plugin/plugin.json` `.version` to match. The `validate-version` PR check enforces this. +2. Merge to `main` with `[major]`, `[minor]`, or `[patch]` anywhere in the commit message. + +The release workflow reads `VERSION`, creates a `vX.Y.Z` git tag, and publishes a GitHub Release with a repo zip attached. No bot push to `main` — the version bump is part of the PR itself. + ## Build order Releases follow a fixed sequence: diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..17e51c3 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.1 From 46babf7b55d16b0697e1dbe27f05d6b9900d21d6 Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Thu, 16 Jul 2026 12:49:07 +0300 Subject: [PATCH 2/6] AX-1837 - Apply PR review feedback: copyright headers, checkout@v5, step comments Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 9 ++++++++- .github/workflows/validate-version.yml | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 468c804..c455135 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,4 @@ +# Copyright (c) JFrog Ltd. 2026 name: Release on: @@ -15,10 +16,12 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Check out the repository with a token to allow pushing tags + - uses: actions/checkout@v5 with: token: ${{ secrets.GITHUB_TOKEN }} + # Parse the commit message to check if a release tag keyword is present - name: Detect release tag in commit message id: detect run: | @@ -29,21 +32,25 @@ jobs: echo "triggered=false" >> "$GITHUB_OUTPUT" fi + # Read the current version from the VERSION file - name: Read version if: steps.detect.outputs.triggered == 'true' id: version run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" + # Create a git tag for the release version and push it to origin - name: Create and push tag if: steps.detect.outputs.triggered == 'true' run: | git tag "v${{ steps.version.outputs.version }}" git push origin "v${{ steps.version.outputs.version }}" + # Package the repository contents into a zip archive for the release - name: Package release artifact if: steps.detect.outputs.triggered == 'true' run: zip -r release.zip . --exclude ".git/*" --exclude ".github/*" + # Create a GitHub Release with the packaged artifact and auto-generated release notes - name: Create GitHub Release if: steps.detect.outputs.triggered == 'true' env: diff --git a/.github/workflows/validate-version.yml b/.github/workflows/validate-version.yml index 5f32229..f16e4af 100644 --- a/.github/workflows/validate-version.yml +++ b/.github/workflows/validate-version.yml @@ -1,3 +1,4 @@ +# Copyright (c) JFrog Ltd. 2026 name: Validate version on: @@ -8,7 +9,7 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Check version consistency run: | From e930db27ba2e364aaeb1835b8f9f01a89a53e9d1 Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Sun, 2 Aug 2026 17:29:37 +0300 Subject: [PATCH 3/6] AX-1837 - Use plugin.json as the single version source; drop the VERSION file .devin-plugin/plugin.json already carries the version Devin reads, so a VERSION file was a second copy that validate-version.yml then had to police against it. Both are gone; release.yml reads the manifest directly. Also refuses to re-tag a version that already shipped (the one mistake a marker-triggered release allows) and reads the commit message from env rather than interpolating it into the script. --- .github/workflows/release.yml | 49 +++++++++++++++++++------- .github/workflows/validate-version.yml | 26 -------------- CONTRIBUTING.md | 6 ++-- VERSION | 1 - 4 files changed, 40 insertions(+), 42 deletions(-) delete mode 100644 .github/workflows/validate-version.yml delete mode 100644 VERSION diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c455135..842bc6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,41 +16,64 @@ jobs: release: runs-on: ubuntu-latest steps: - # Check out the repository with a token to allow pushing tags + # Full history so the "already released" check below can see existing tags. - uses: actions/checkout@v5 with: + fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - # Parse the commit message to check if a release tag keyword is present - - name: Detect release tag in commit message + # Releasing is opt-in: only a merge whose commit message carries [major], [minor] or + # [patch] cuts one. The marker is purely a trigger — the version itself always comes from + # the manifest, so the bump is reviewable in the PR that makes it. + # The message is passed through env rather than interpolated into the script, so a commit + # subject can never inject shell. + - name: Detect release marker in commit message id: detect + env: + MSG: ${{ github.event.head_commit.message }} run: | - MSG="${{ github.event.head_commit.message }}" - if echo "$MSG" | grep -qE '\[(major|minor|patch)\]'; then + if printf '%s' "$MSG" | grep -qE '\[(major|minor|patch)\]'; then echo "triggered=true" >> "$GITHUB_OUTPUT" else echo "triggered=false" >> "$GITHUB_OUTPUT" fi - # Read the current version from the VERSION file - - name: Read version + # .devin-plugin/plugin.json is the single source of truth for the version — it is the file + # Devin itself reads. Nothing duplicates it, so there is no drift to police. + - name: Read version from the plugin manifest if: steps.detect.outputs.triggered == 'true' id: version - run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" + 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" + + # Catches the one mistake this flow allows: merging a release marker without bumping the + # manifest, which would otherwise try to re-tag a version that already shipped. + - 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 - # Create a git tag for the release version and push it to origin - name: Create and push tag if: steps.detect.outputs.triggered == 'true' run: | - git tag "v${{ steps.version.outputs.version }}" - git push origin "v${{ steps.version.outputs.version }}" + TAG="v${{ steps.version.outputs.version }}" + git tag "$TAG" + git push origin "$TAG" - # Package the repository contents into a zip archive for the release - name: Package release artifact if: steps.detect.outputs.triggered == 'true' run: zip -r release.zip . --exclude ".git/*" --exclude ".github/*" - # Create a GitHub Release with the packaged artifact and auto-generated release notes - name: Create GitHub Release if: steps.detect.outputs.triggered == 'true' env: diff --git a/.github/workflows/validate-version.yml b/.github/workflows/validate-version.yml deleted file mode 100644 index f16e4af..0000000 --- a/.github/workflows/validate-version.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright (c) JFrog Ltd. 2026 -name: Validate version - -on: - pull_request: - branches: [main] - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Check version consistency - run: | - VERSION=$(cat VERSION) - FAILED=0 - - PLUGIN_VERSION=$(jq -r '.version' .devin-plugin/plugin.json) - if [ "$VERSION" != "$PLUGIN_VERSION" ]; then - echo "::error::Version mismatch: VERSION=$VERSION but .devin-plugin/plugin.json.version=$PLUGIN_VERSION" - FAILED=1 - fi - - [ "$FAILED" -eq 0 ] && echo "All versions consistent: $VERSION" - exit $FAILED diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f8ab67..4a542be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,10 +52,12 @@ This downloads the pinned upstream tarball and replaces the contents of `skills/ To cut a release: -1. In your PR, bump `VERSION` and sync `.devin-plugin/plugin.json` `.version` to match. The `validate-version` PR check enforces this. +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]` anywhere in the commit message. -The release workflow reads `VERSION`, creates a `vX.Y.Z` git tag, and publishes a GitHub Release with a repo zip attached. No bot push to `main` — the version bump is part of the PR itself. +The release workflow reads the version from the manifest, creates a `vX.Y.Z` git tag, and publishes a GitHub Release with a repo zip attached. 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. ## Build order diff --git a/VERSION b/VERSION deleted file mode 100644 index 17e51c3..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.1 From 3da8564681740e911f7d5aec45b2d5eae0c86121 Mon Sep 17 00:00:00 2001 From: Yoni Melki Date: Mon, 3 Aug 2026 09:58:53 +0300 Subject: [PATCH 4/6] AX-1837 - Trigger releases from the commit subject only These repos squash-merge, and GitHub pre-fills the squash message body from the branch's commit messages (or the PR description). Both of those quote [major]/[minor]/[patch] while only documenting the flow, so matching the whole message meant merging this very PR would have cut a release nobody asked for. Matching the subject line alone keeps the trigger deliberate: a release happens when someone writes the marker in the subject they are merging, not when a marker happens to appear in generated body text. --- .github/workflows/release.yml | 11 ++++++++--- CONTRIBUTING.md | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 842bc6b..3d7e5b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,17 +22,22 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - # Releasing is opt-in: only a merge whose commit message carries [major], [minor] or + # Releasing is opt-in: only a merge whose commit *subject* carries [major], [minor] or # [patch] cuts one. The marker is purely a trigger — the version itself always comes from # the manifest, so the bump is reviewable in the PR that makes it. + # Subject line only, deliberately. This repo squash-merges, and GitHub pre-fills the squash + # message body from the branch's commit messages (or the PR description). Either one can + # mention a marker while merely documenting it — matching the whole message would then + # release by accident on a merge nobody intended as a release. # The message is passed through env rather than interpolated into the script, so a commit # subject can never inject shell. - - name: Detect release marker in commit message + - name: Detect release marker in commit subject id: detect env: MSG: ${{ github.event.head_commit.message }} run: | - if printf '%s' "$MSG" | grep -qE '\[(major|minor|patch)\]'; then + 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" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a542be..182babf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,10 @@ 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]` anywhere in the commit message. +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 release workflow reads the version from the manifest, creates a `vX.Y.Z` git tag, and publishes a GitHub Release with a repo zip attached. 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`. From b2b8b1bf2db1764ead3ba5450842a9bd4cdad145 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 3 Aug 2026 14:22:35 +0300 Subject: [PATCH 5/6] AX-1837 - Gate the release on validation, and create the tag with the release Three fixes from review, all in release.yml. An orphan tag was possible: the tag was pushed in its own step before `gh release create`, so a failure in between left a tag with no release behind it. The re-run then hit the "already exists" check with nothing actually wrong but the tag, and it needed deleting by hand. The tag is now created by `gh release create --target "$GITHUB_SHA"` in the same API call as the release, so there is no window between the two. Nothing pushes over git anymore, so the write token is gone from the checkout step. Releases were not gated on validation. The validate workflow triggers on the same push, but as a separate workflow with no relationship to this one, so it could still be red while a release went out. The same check now runs here, before the release is created. `zip -r release.zip .` packed the working tree, so anything an earlier step left on the runner would ship inside the artifact. Replaced with `git archive`, which exports tracked files at HEAD, still excluding .github. --- .github/workflows/release.yml | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d7e5b0..d40a850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,11 +16,11 @@ jobs: release: runs-on: ubuntu-latest steps: - # Full history so the "already released" check below can see existing tags. + # Full history (and therefore tags) so the "already released" check below can see them. + # No write token needed: nothing here pushes over git, the release API creates the tag. - uses: actions/checkout@v5 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} # Releasing is opt-in: only a merge whose commit *subject* carries [major], [minor] or # [patch] cuts one. The marker is purely a trigger — the version itself always comes from @@ -58,7 +58,8 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" # Catches the one mistake this flow allows: merging a release marker without bumping the - # manifest, which would otherwise try to re-tag a version that already shipped. + # manifest. The tag is created as part of the release below, so a tag that already exists + # means that version genuinely shipped. - name: Refuse to re-release an existing version if: steps.detect.outputs.triggered == 'true' run: | @@ -68,17 +69,27 @@ jobs: exit 1 fi - - name: Create and push tag + - uses: actions/setup-node@v5 if: steps.detect.outputs.triggered == 'true' - run: | - TAG="v${{ steps.version.outputs.version }}" - git tag "$TAG" - git push origin "$TAG" + with: + node-version: "24" + + # The plugin layout check runs on this same push, but as a separate workflow — in parallel and with + # no relationship to this one — so on its own it cannot stop a broken plugin layout from being + # released. Running the same 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 + # git archive exports tracked files at HEAD, so nothing an earlier step happened to leave on + # the runner can end up in the artifact. .github is excluded to match what users get. - name: Package release artifact if: steps.detect.outputs.triggered == 'true' - run: zip -r release.zip . --exclude ".git/*" --exclude ".github/*" + run: git archive --format=zip --output=release.zip HEAD -- ':(exclude).github' + # --target creates the tag as part of creating the release, in a single API call. Pushing the + # tag separately beforehand meant a failure in between left an orphan tag with no release, and + # the re-run then tripped the "already exists" check above with nothing wrong but the tag. - name: Create GitHub Release if: steps.detect.outputs.triggered == 'true' env: @@ -86,5 +97,6 @@ jobs: run: | gh release create "v${{ steps.version.outputs.version }}" \ release.zip \ + --target "$GITHUB_SHA" \ --title "Release v${{ steps.version.outputs.version }}" \ --generate-notes From 68b80884bfdbc3dd82a01aae5310c13e7a03b289 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 4 Aug 2026 11:10:57 +0300 Subject: [PATCH 6/6] AX-1837 - Move the release rationale out of the workflow and into CONTRIBUTING Review feedback: the per-step comments in release.yml had grown into several paragraphs of rationale, which is documentation rather than a code comment. Each step now carries at most two lines - what it does, or the one constraint a reader could otherwise undo by "simplifying" it: subject-line matching, env rather than interpolation, git archive rather than the working tree, --target creating the tag. A pointer at the top of the file sends readers to CONTRIBUTING.md for the full flow. CONTRIBUTING.md gains the parts the comments had that it did not already say: what the workflow does in order, what ends up in the release zip, why validation runs inside the release job instead of relying on the separate validate workflow, and why the tag is created by the release rather than pushed before it. No behaviour change - the release.yml diff is comments only. --- .github/workflows/release.yml | 37 ++++++++++++----------------------- CONTRIBUTING.md | 11 ++++++++--- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d40a850..55bcdae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,7 @@ # 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: @@ -16,21 +19,13 @@ jobs: release: runs-on: ubuntu-latest steps: - # Full history (and therefore tags) so the "already released" check below can see them. - # No write token needed: nothing here pushes over git, the release API creates the tag. + # Full history, so the tag check below can see existing tags. - uses: actions/checkout@v5 with: fetch-depth: 0 - # Releasing is opt-in: only a merge whose commit *subject* carries [major], [minor] or - # [patch] cuts one. The marker is purely a trigger — the version itself always comes from - # the manifest, so the bump is reviewable in the PR that makes it. - # Subject line only, deliberately. This repo squash-merges, and GitHub pre-fills the squash - # message body from the branch's commit messages (or the PR description). Either one can - # mention a marker while merely documenting it — matching the whole message would then - # release by accident on a merge nobody intended as a release. - # The message is passed through env rather than interpolated into the script, so a commit - # subject can never inject shell. + # 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: @@ -43,8 +38,7 @@ jobs: echo "triggered=false" >> "$GITHUB_OUTPUT" fi - # .devin-plugin/plugin.json is the single source of truth for the version — it is the file - # Devin itself reads. Nothing duplicates it, so there is no drift to police. + # The manifest is the only place the version lives. - name: Read version from the plugin manifest if: steps.detect.outputs.triggered == 'true' id: version @@ -57,9 +51,8 @@ jobs: fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # Catches the one mistake this flow allows: merging a release marker without bumping the - # manifest. The tag is created as part of the release below, so a tag that already exists - # means that version genuinely shipped. + # 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: | @@ -74,22 +67,18 @@ jobs: with: node-version: "24" - # The plugin layout check runs on this same push, but as a separate workflow — in parallel and with - # no relationship to this one — so on its own it cannot stop a broken plugin layout from being - # released. Running the same check here is what actually gates the release on it. + # 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 - # git archive exports tracked files at HEAD, so nothing an earlier step happened to leave on - # the runner can end up in the artifact. .github is excluded to match what users get. + # 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 creating the release, in a single API call. Pushing the - # tag separately beforehand meant a failure in between left an orphan tag with no release, and - # the re-run then tripped the "already exists" check above with nothing wrong but the tag. + # --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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 182babf..90e98d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,14 +53,19 @@ 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 +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 release workflow reads the version from the manifest, creates a `vX.Y.Z` git tag, and publishes a GitHub Release with a repo zip attached. 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`. +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. -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