diff --git a/.github/scripts/publish-packages.js b/.github/scripts/publish-packages.js index 9fe711e..54dac9a 100644 --- a/.github/scripts/publish-packages.js +++ b/.github/scripts/publish-packages.js @@ -63,6 +63,7 @@ try { } const version = normalizedVersion.npmVersion; +const distTag = normalizedVersion.distTag; const rootPackagePath = path.join(ROOT, "package.json"); const rootPackage = JSON.parse(fs.readFileSync(rootPackagePath, "utf8")); const platforms = rootPackage.customerioCli?.platforms || []; @@ -183,6 +184,9 @@ function publish(packageDir) { const args = dryRun ? ["publish", "--dry-run", "--access", "public", "--registry", registry.url] : ["publish", "--access", "public", "--registry", registry.url]; + if (distTag !== "latest") { + args.push("--tag", distTag); + } args.push(...registry.publishArgs); execFileSync("npm", args, { cwd: packageDir, stdio: "inherit" }); } diff --git a/.github/scripts/release-version.js b/.github/scripts/release-version.js index a605985..e0c2edf 100644 --- a/.github/scripts/release-version.js +++ b/.github/scripts/release-version.js @@ -1,18 +1,25 @@ function normalizeVersion(input) { const match = String(input || "") .trim() - .match(/^v?(\d+\.\d+\.\d+)$/); + .match(/^v?(\d+\.\d+\.\d+(?:-([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)*))?)$/); if (!match) { - throw new Error("version must use the exact X.Y.Z or vX.Y.Z format"); + throw new Error( + "version must use X.Y.Z or X.Y.Z- format (e.g. 1.2.3, 1.2.3-alpha.1)" + ); } const version = match[1]; + const prerelease = match[2] || null; const tag = `v${version}`; + const distTag = prerelease ? prerelease.split(".")[0] : "latest"; + return { npmVersion: version, tag, tagRef: `refs/tags/${tag}`, + prerelease, + distTag, }; } diff --git a/.github/scripts/release-workflow.js b/.github/scripts/release-workflow.js index 0a48b61..205b519 100644 --- a/.github/scripts/release-workflow.js +++ b/.github/scripts/release-workflow.js @@ -49,6 +49,13 @@ function validateDispatch(env = process.env) { return ctx; } + if (ctx.prerelease) { + if (!ctx.githubRef.startsWith("refs/heads/") && ctx.githubRef !== ctx.tagRef) { + fail(`prerelease must be dispatched from a branch or ${ctx.tagRef}`); + } + return ctx; + } + if (ctx.githubRef !== "refs/heads/main" && ctx.githubRef !== ctx.tagRef) { fail(`real release must be dispatched from refs/heads/main or ${ctx.tagRef}`); } @@ -166,15 +173,23 @@ function assertLocalTagDoesNotExist(tag) { function tagAndDispatch(env = process.env) { const ctx = validateDispatch(env); - if (ctx.dryRun || ctx.resumeExistingNpm || ctx.githubRef !== "refs/heads/main") { - fail("tag-and-dispatch is only valid for real releases dispatched from refs/heads/main"); + if (ctx.dryRun || ctx.resumeExistingNpm) { + fail("tag-and-dispatch is only valid for real releases dispatched from a branch"); + } + 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); - assertOriginMainSha(ctx); + if (!ctx.prerelease) { + assertOriginMainSha(ctx); + } assertLocalTagDoesNotExist(ctx.tag); if (remoteTagExists(ctx.tag)) { fail(`tag ${ctx.tag} already exists on origin`); diff --git a/.github/scripts/release-workflow.test.js b/.github/scripts/release-workflow.test.js index 4a4e5c4..fa77e27 100644 --- a/.github/scripts/release-workflow.test.js +++ b/.github/scripts/release-workflow.test.js @@ -20,14 +20,39 @@ assert.deepStrictEqual(normalizeVersion("1.2.3"), { npmVersion: "1.2.3", tag: "v1.2.3", tagRef: "refs/tags/v1.2.3", + prerelease: null, + distTag: "latest", }); assert.deepStrictEqual(normalizeVersion("v1.2.3"), { npmVersion: "1.2.3", tag: "v1.2.3", tagRef: "refs/tags/v1.2.3", + prerelease: null, + distTag: "latest", +}); +assert.deepStrictEqual(normalizeVersion("1.2.3-alpha.1"), { + npmVersion: "1.2.3-alpha.1", + tag: "v1.2.3-alpha.1", + tagRef: "refs/tags/v1.2.3-alpha.1", + prerelease: "alpha.1", + distTag: "alpha", +}); +assert.deepStrictEqual(normalizeVersion("v1.2.3-beta.2"), { + npmVersion: "1.2.3-beta.2", + tag: "v1.2.3-beta.2", + tagRef: "refs/tags/v1.2.3-beta.2", + prerelease: "beta.2", + distTag: "beta", +}); +assert.deepStrictEqual(normalizeVersion("1.0.0-rc.1"), { + npmVersion: "1.0.0-rc.1", + tag: "v1.0.0-rc.1", + tagRef: "refs/tags/v1.0.0-rc.1", + prerelease: "rc.1", + distTag: "rc", }); -for (const version of ["v1", "1.2", "version=foo", "1.2.3-beta.1", "1.2.3+build.1"]) { +for (const version of ["v1", "1.2", "version=foo", "1.2.3+build.1"]) { assert.throws(() => normalizeVersion(version), /version must use/); } @@ -72,6 +97,27 @@ assert.throws( /real release must be dispatched/ ); +// prerelease: allowed from any branch or matching tag +assert.doesNotThrow(() => + validateDispatch(env({ + VERSION_INPUT: "1.2.3-alpha.1", + GITHUB_REF: "refs/heads/my-feature-branch", + })) +); +assert.doesNotThrow(() => + validateDispatch(env({ + VERSION_INPUT: "1.2.3-alpha.1", + GITHUB_REF: "refs/tags/v1.2.3-alpha.1", + })) +); +assert.throws( + () => validateDispatch(env({ + VERSION_INPUT: "1.2.3-alpha.1", + GITHUB_REF: "refs/tags/v1.2.3-alpha.2", + })), + /prerelease must be dispatched/ +); + assert.doesNotThrow(() => validateDispatch(env({ DRY_RUN: "false", @@ -100,6 +146,15 @@ assert.deepStrictEqual(resolveVersion({ VERSION_INPUT: "v1.2.3" }), { npmVersion: "1.2.3", tag: "v1.2.3", tagRef: "refs/tags/v1.2.3", + prerelease: null, + distTag: "latest", +}); +assert.deepStrictEqual(resolveVersion({ VERSION_INPUT: "1.0.0-alpha.1" }), { + npmVersion: "1.0.0-alpha.1", + tag: "v1.0.0-alpha.1", + tagRef: "refs/tags/v1.0.0-alpha.1", + prerelease: "alpha.1", + distTag: "alpha", }); console.log("release-workflow tests passed"); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e447a9..c4b15bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: version: - description: "Release version, e.g. 1.2.3 (blank = auto-bump the latest tag)" + description: "Release version, e.g. 1.2.3 or 1.2.3-alpha.1 (blank = auto-bump the latest tag)" required: false default: "" type: string @@ -71,7 +71,7 @@ jobs: ${{ !inputs.dry_run && !inputs.resume_existing_npm && - github.ref == 'refs/heads/main' + startsWith(github.ref, 'refs/heads/') }} runs-on: ubuntu-latest permissions: