From 94f5eeac53273fb73bf581709660e027789a1a2d Mon Sep 17 00:00:00 2001 From: Lucas Bebber Date: Wed, 17 Jun 2026 18:27:23 -0300 Subject: [PATCH] Add ref input to release workflow and enforce main-only stable releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `ref` text field to the Release workflow so users can specify which branch to build from without using the branch dropdown. Enforce that stable versions (X.Y.Z) can only be released from main — non-main branches require a prerelease version (e.g. X.Y.Z-alpha.1). --- .github/scripts/release-workflow.js | 35 ++++++++++++---- .github/scripts/release-workflow.test.js | 53 +++++++++++++++++++++++- .github/workflows/release.yml | 16 +++++-- 3 files changed, 91 insertions(+), 13 deletions(-) diff --git a/.github/scripts/release-workflow.js b/.github/scripts/release-workflow.js index 205b519..393c3b7 100644 --- a/.github/scripts/release-workflow.js +++ b/.github/scripts/release-workflow.js @@ -26,9 +26,16 @@ function releaseContext(env = process.env) { githubRef: env.GITHUB_REF || "", githubRepository: env.GITHUB_REPOSITORY || "", githubSha: env.GITHUB_SHA || "", + refInput: env.REF_INPUT || "", }; } +function effectiveRef(ctx) { + if (ctx.refInput) return ctx.refInput; + const match = ctx.githubRef.match(/^refs\/heads\/(.+)$/); + return match ? match[1] : null; +} + function validateDispatch(env = process.env) { const ctx = releaseContext(env); @@ -49,6 +56,11 @@ function validateDispatch(env = process.env) { return ctx; } + const ref = effectiveRef(ctx); + if (!ctx.prerelease && ref && ref !== "main") { + fail(`stable releases require ref 'main'; use a prerelease version for branch '${ref}'`); + } + if (ctx.prerelease) { if (!ctx.githubRef.startsWith("refs/heads/") && ctx.githubRef !== ctx.tagRef) { fail(`prerelease must be dispatched from a branch or ${ctx.tagRef}`); @@ -179,23 +191,27 @@ function tagAndDispatch(env = process.env) { if (!ctx.githubRef.startsWith("refs/heads/")) { fail("tag-and-dispatch must be dispatched from a branch"); } - if (!ctx.prerelease && ctx.githubRef !== "refs/heads/main") { - fail("stable releases must be dispatched from refs/heads/main"); - } if (!ctx.githubRepository) { fail("GITHUB_REPOSITORY must be set"); } - assertCheckoutSha(ctx); - if (!ctx.prerelease) { - assertOriginMainSha(ctx); + const isCustomRef = Boolean(ctx.refInput) && ctx.refInput !== "main"; + const commitSha = isCustomRef + ? read("git", ["rev-parse", "HEAD"]) + : ctx.githubSha; + + if (!isCustomRef) { + assertCheckoutSha(ctx); + if (!ctx.prerelease) { + assertOriginMainSha(ctx); + } } assertLocalTagDoesNotExist(ctx.tag); if (remoteTagExists(ctx.tag)) { fail(`tag ${ctx.tag} already exists on origin`); } - run("git", ["tag", ctx.tag, ctx.githubSha]); + run("git", ["tag", ctx.tag, commitSha]); run("git", ["push", "origin", `refs/tags/${ctx.tag}`]); run("gh", [ "workflow", @@ -217,7 +233,9 @@ function tagAndDispatch(env = process.env) { function assertDispatchCheckout(env = process.env) { const ctx = validateDispatch(env); - assertCheckoutSha(ctx); + if (!ctx.refInput || ctx.refInput === "main") { + assertCheckoutSha(ctx); + } return ctx; } @@ -309,6 +327,7 @@ if (require.main === module) { module.exports = { releaseContext, + effectiveRef, validateDispatch, latestTag, resolveVersion, diff --git a/.github/scripts/release-workflow.test.js b/.github/scripts/release-workflow.test.js index fa77e27..bf00925 100644 --- a/.github/scripts/release-workflow.test.js +++ b/.github/scripts/release-workflow.test.js @@ -2,7 +2,7 @@ const assert = require("assert"); const { normalizeVersion, bumpVersion } = require("./release-version"); -const { validateDispatch, resolveVersion } = require("./release-workflow"); +const { effectiveRef, validateDispatch, resolveVersion } = require("./release-workflow"); function env(overrides) { return { @@ -130,6 +130,57 @@ assert.throws( /recovery must be dispatched/ ); +// effectiveRef: returns refInput when set, otherwise branch from githubRef +assert.strictEqual( + effectiveRef({ refInput: "my-branch", githubRef: "refs/heads/main" }), + "my-branch" +); +assert.strictEqual( + effectiveRef({ refInput: "", githubRef: "refs/heads/main" }), + "main" +); +assert.strictEqual( + effectiveRef({ refInput: "", githubRef: "refs/tags/v1.2.3" }), + null +); + +// ref input: stable version + non-main ref must fail +assert.throws( + () => validateDispatch(env({ REF_INPUT: "my-branch" })), + /stable releases require ref 'main'/ +); +// ref input: stable version + main ref is fine +assert.doesNotThrow(() => + validateDispatch(env({ REF_INPUT: "main" })) +); +// ref input: stable version + empty ref (default) from main is fine +assert.doesNotThrow(() => + validateDispatch(env({ REF_INPUT: "" })) +); +// ref input: prerelease + non-main ref is fine +assert.doesNotThrow(() => + validateDispatch(env({ + VERSION_INPUT: "1.2.3-alpha.1", + REF_INPUT: "my-branch", + })) +); +// dispatch from non-main branch (via dropdown) without ref input: stable version must fail +assert.throws( + () => validateDispatch(env({ + GITHUB_REF: "refs/heads/feature-branch", + REF_INPUT: "", + })), + /stable releases require ref 'main'/ +); +// dispatch from non-main branch (via dropdown) with prerelease: fine +assert.doesNotThrow(() => + validateDispatch(env({ + VERSION_INPUT: "1.2.3-alpha.1", + GITHUB_REF: "refs/heads/feature-branch", + REF_INPUT: "", + })) +); + // bumpVersion: level handling and v-prefix tolerance assert.strictEqual(bumpVersion("0.0.11", "patch"), "0.0.12"); assert.strictEqual(bumpVersion("v0.0.11", "patch"), "0.0.12"); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4b15bb..a870a8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Release run-name: >- - ${{ inputs.dry_run && 'Release dry-run' || 'Release' }} + ${{ inputs.dry_run && 'Release dry-run' || 'Release' }}${{ inputs.version && format(' {0}', inputs.version) || '' }}${{ inputs.ref && format(' (ref: {0})', inputs.ref) || '' }} on: workflow_dispatch: @@ -11,6 +11,11 @@ on: required: false default: "" type: string + ref: + description: "Branch to release from (default: dispatch branch). Non-main branches require a prerelease version." + required: false + default: "" + type: string bump: description: "Bump level when version is blank" required: false @@ -49,7 +54,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - ref: ${{ github.sha }} + ref: ${{ inputs.ref || github.sha }} - name: Resolve release version id: resolve @@ -63,6 +68,7 @@ jobs: VERSION_INPUT: ${{ steps.resolve.outputs.version }} DRY_RUN: ${{ inputs.dry_run }} RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + REF_INPUT: ${{ inputs.ref }} run: node .github/scripts/release-workflow.js assert-dispatch-checkout tag_and_dispatch: @@ -81,7 +87,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - ref: ${{ github.sha }} + ref: ${{ inputs.ref || github.sha }} - name: Create tag and dispatch release run env: @@ -89,6 +95,7 @@ jobs: VERSION_INPUT: ${{ needs.validate_dispatch.outputs.version }} DRY_RUN: ${{ inputs.dry_run }} RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + REF_INPUT: ${{ inputs.ref }} run: node .github/scripts/release-workflow.js tag-and-dispatch dry-run: @@ -101,13 +108,14 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - ref: ${{ github.sha }} + ref: ${{ inputs.ref || github.sha }} - name: Assert checkout ref env: VERSION_INPUT: ${{ needs.validate_dispatch.outputs.version }} DRY_RUN: ${{ inputs.dry_run }} RESUME_EXISTING_NPM: ${{ inputs.resume_existing_npm }} + REF_INPUT: ${{ inputs.ref }} run: node .github/scripts/release-workflow.js assert-dispatch-checkout - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0