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