From 866575add55441f5cd17ecaa7bf9835f589b7a5a Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 26 Jul 2026 23:34:52 +0000 Subject: [PATCH] Correct staged release and merge gates --- .github/workflows/finalise-release.yml | 14 ++++- devs.md | 29 +++++----- utils/release-pr-body.mjs | 34 ++++++++---- utils/release-process.unit.spec.ts | 73 ++++++++++++++++---------- 4 files changed, 100 insertions(+), 50 deletions(-) diff --git a/.github/workflows/finalise-release.yml b/.github/workflows/finalise-release.yml index 2991d511..620c0915 100644 --- a/.github/workflows/finalise-release.yml +++ b/.github/workflows/finalise-release.yml @@ -62,6 +62,7 @@ jobs: VERSION: ${{ inputs.version }} EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }} PRERELEASE: ${{ inputs.prerelease }} + PUBLISH_CLI: ${{ inputs.publish_cli }} run: | set -euo pipefail ACTUAL_HEAD_SHA="$(git rev-parse HEAD)" @@ -73,6 +74,10 @@ jobs: echo "Version ${VERSION} is a pre-release version, but prerelease was not enabled." >&2 exit 1 fi + if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then + echo "A stable version staged as a pre-release must use publish_cli=false so that the CLI latest and major-minor image tags do not advance before BRAT validation." >&2 + exit 1 + fi node utils/release-notes.mjs validate "${VERSION}" - name: Ensure and push release tags @@ -120,8 +125,13 @@ jobs: echo "" echo "Dispatched the plug-in release workflow for \`${VERSION}\`. After approval for the release environment, it creates a draft GitHub Release." echo "" - if [[ "${PRERELEASE}" == "true" ]]; then - echo "Publish the draft as a pre-release, keep the release pull request in draft, and merge only after BRAT validation succeeds." + if [[ "${VERSION}" == *-* ]]; then + echo "Publish the draft as a pre-release without replacing the latest stable release." + echo "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action." + elif [[ "${PRERELEASE}" == "true" ]]; then + echo "Publish the draft initially as a pre-release without replacing the latest stable release." + echo "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before merging the release pull request." + echo "Create the stable CLI tag and publish its latest and major-minor image tags through a separate maintainer gate." else echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds." fi diff --git a/devs.md b/devs.md index a27bebc1..2951e8e5 100644 --- a/devs.md +++ b/devs.md @@ -244,7 +244,9 @@ export class ModuleExample extends AbstractObsidianModule { - Use SemVer beta identifiers such as `1.0.0-beta.0` for immutable integration previews. Increment the beta number when a published preview needs a correction. Reserve `1.0.0-rc.0` for the first feature- and contract-frozen release candidate. Historical `-patchedN` releases remain unchanged in the release history. - Publish a pre-release from an immutable reviewed tag, mark its GitHub Release as a pre-release, and do not replace the latest stable release. - A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag. -- Keep the release pull request in draft until the exact published plug-in has passed BRAT validation. If validation fails, prepare the next pre-release version rather than moving the existing tag. +- Keep a hyphenated pre-release's release pull request in draft and unmerged after BRAT validation. Reconcile the published version's metadata into its base branch through a reviewed metadata-only commit, then close the release pull request only through a separate maintainer action. +- Stage a stable version for BRAT by publishing its exact `x.y.z` tag initially as a GitHub pre-release with `prerelease=true` and `publish_cli=false`. The stable manifest version would otherwise make the CLI workflow advance `latest` and the major-minor image tag before validation. +- If validation fails, leave every published tag unchanged and prepare the next pre-release or patch version. ## Release Notes @@ -264,13 +266,15 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel - Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`. - Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, ensures that the plug-in tag points to that commit, optionally creates the corresponding CLI tag, and dispatches the plug-in release workflow. A CLI tag starts its own container workflow. The finalisation workflow can be retried when existing tags already point to the reviewed commit, but stops if a selected tag points elsewhere. - The plug-in publishing workflow is intentionally dispatch-only. Pushing a plug-in tag directly does not publish a GitHub Release; use `Finalise Release Tags`, or dispatch `Release Obsidian Plugin` explicitly for recovery or a pre-release. The CLI Docker workflow retains its documented branch, tag, and manual triggers. -- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. For a selected CLI publication, confirm the image tags appropriate to a stable or pre-release version. -- Publish a stable draft as the latest release, or publish a pre-release draft without replacing the latest stable release. In either case, keep the release PR in draft and leave its base branch unchanged until BRAT validation succeeds. Record that state in the PR. +- For a hyphenated pre-release, run finalisation with `prerelease=true`; CLI publication remains optional. For a stable version awaiting BRAT validation, use `prerelease=true` and `publish_cli=false`. +- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. When a hyphenated pre-release includes the CLI, confirm that it received only its immutable version and SHA-qualified image tags. +- Publish the draft as a GitHub pre-release without replacing the latest stable release. Keep its release pull request in draft and leave its base branch unchanged throughout BRAT validation. Record that state in the pull request. - Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release. -- After BRAT validation succeeds, mark the release PR ready and merge it into the selected base branch with a merge commit. This keeps the tagged release commit in that branch's history. +- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action. +- After a stable version passes, remove its GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate. Only then mark the stable release pull request ready and merge it into the selected base branch with a merge commit. - If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show :updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history. - Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action. -- For a pre-release, set `prerelease=true` in `Finalise Release Tags`. A hyphenated version is rejected unless that input is enabled. +- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`. ### Release Cheat Sheet @@ -290,15 +294,16 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel - `version`: the same target version. - `release_branch`: leave blank unless the release branch used a custom name. - `expected_head_sha`: the full head commit SHA reviewed in the release PR. - - `prerelease`: enable for a version such as `1.0.0-rc.0`. - - `publish_cli`: disable when the reviewed release is plug-in-only. + - `prerelease`: enable for a version such as `1.0.0-rc.0`, and also when staging a stable version for BRAT. + - `publish_cli`: optional for a hyphenated pre-release, but disable it when staging a stable version. 5. Approve the `Release Obsidian Plugin` workflow for the `release` environment, then check the generated draft GitHub Release. -6. If CLI publication was selected, confirm that the CLI tag event published the expected image tags. -7. Publish the draft as a stable release or pre-release as selected, but keep the release PR in draft and leave its base branch unchanged. -8. Update the PR state message to describe the published release and state that merging remains on hold until BRAT validation is complete. +6. If a hyphenated pre-release includes the CLI, confirm that the CLI tag event published only immutable version and SHA-qualified image tags. +7. Publish the draft as a GitHub pre-release without replacing the latest stable release, but keep the release PR in draft and leave its base branch unchanged. +8. Update the PR state message to describe the published pre-release and state that merging remains on hold. 9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario. -10. After BRAT validation succeeds, mark the release PR ready and merge it into the selected base branch with a merge commit. -11. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries. +10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action. +11. After a stable version passes, remove its pre-release designation, make the exact release the latest stable release, publish the stable CLI tags through a separate maintainer gate if selected, then mark the release PR ready and merge it into the selected base branch. +12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries. ## Contribution Guidelines diff --git a/utils/release-pr-body.mjs b/utils/release-pr-body.mjs index 71a929d3..f48f159f 100644 --- a/utils/release-pr-body.mjs +++ b/utils/release-pr-body.mjs @@ -20,9 +20,10 @@ function inlineCode(value) { /** * Render the reader-facing checklist for a draft release pull request. * - * The version decides whether publication is stable or pre-release. The base - * branch is included explicitly because integration previews can target a - * reviewed integration branch rather than `main`. + * The version decides whether the release commit is an immutable SemVer + * pre-release or a stable version staged through a GitHub pre-release. The + * base branch is included explicitly because integration previews can target + * a reviewed integration branch rather than `main`. * * @param {string} version * @param {string} baseBranch @@ -39,13 +40,28 @@ export function renderReleasePrBody(version, baseBranch) { const isPrerelease = selectedVersion.includes("-"); const purpose = isPrerelease ? `an immutable pre-release for BRAT validation without replacing the latest stable release` - : `the next stable release`; + : `a stable version which will first be staged as a GitHub pre-release for BRAT validation`; const finaliseInstruction = isPrerelease ? "Run the finalise release workflow with this PR's fixed head SHA and `prerelease=true`" - : "Run the finalise release workflow with this PR's fixed head SHA and `prerelease=false`"; + : "Run the finalise release workflow with this PR's fixed head SHA, `prerelease=true`, and `publish_cli=false`"; const publicationInstruction = isPrerelease ? "Publish the GitHub Release as a pre-release without replacing the latest stable release, while keeping this pull request in draft" - : "Publish the GitHub Release as the latest stable release while keeping this pull request in draft"; + : "Publish the GitHub Release initially as a pre-release without replacing the latest stable release, while keeping this pull request in draft"; + const assetInstruction = isPrerelease + ? "Confirm the draft GitHub Release assets and the published CLI image, if selected" + : "Confirm the draft GitHub Release assets; keep stable CLI publication deferred until BRAT validation passes"; + const holdInstruction = isPrerelease + ? `Publishing and validating this pre-release does not unblock this pull request. Keep it in draft and unmerged, and leave ${baseBranchCode} unchanged.` + : `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation and has been promoted to the latest stable release.`; + const completionInstructions = isPrerelease + ? [ + "- [ ] Keep this pre-release pull request unmerged; close it only through a separate maintainer action", + ] + : [ + "- [ ] Remove the pre-release designation and make this exact release the latest stable release", + "- [ ] Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate", + `- [ ] Mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`, + ]; return [ `This release pull request prepares Self-hosted LiveSync ${versionCode} from ${baseBranchCode} as ${purpose}.`, @@ -53,7 +69,7 @@ export function renderReleasePrBody(version, baseBranch) { "> [!IMPORTANT]", "> **Merge intentionally on hold**", ">", - `> Publishing the GitHub Release does not unblock this pull request. Keep this pull request in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation.`, + `> ${holdInstruction}`, "", "## Release checklist", "", @@ -62,10 +78,10 @@ export function renderReleasePrBody(version, baseBranch) { "- [ ] Confirm `manifest.json`, `versions.json`, workspace package versions, and the locked Commonlib package version", "- [ ] Confirm CI has passed", `- [ ] ${finaliseInstruction}`, - "- [ ] Confirm the draft GitHub Release assets and the published CLI image, if selected", + `- [ ] ${assetInstruction}`, `- [ ] ${publicationInstruction}`, "- [ ] Validate the exact published release with BRAT", - `- [ ] Mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`, + ...completionInstructions, "", ].join("\n"); } diff --git a/utils/release-process.unit.spec.ts b/utils/release-process.unit.spec.ts index abdb7a05..62338eb0 100644 --- a/utils/release-process.unit.spec.ts +++ b/utils/release-process.unit.spec.ts @@ -4,10 +4,10 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { renderReleasePrBody } from "./release-pr-body.mjs"; import { ensureTags } from "./release-tags.mjs"; const releaseNotesScript = fileURLToPath(new URL("./release-notes.mjs", import.meta.url)); -const releasePrBodyScript = fileURLToPath(new URL("./release-pr-body.mjs", import.meta.url)); const versionBumpScript = process.env.VERSION_BUMP_SCRIPT || fileURLToPath(new URL("../version-bump.mjs", import.meta.url)); const workspaceUpdateScript = fileURLToPath(new URL("../update-workspaces.mjs", import.meta.url)); @@ -160,13 +160,12 @@ describe("release notes", () => { describe("release workflow", () => { it("uses the locked Commonlib package instead of generated fallback declarations", () => { const workflow = readFileSync(prepareReleaseWorkflow, "utf8"); - const body = runNode(releasePrBodyScript, ["1.0.0-beta.0", "integration"], makeTemporaryDirectory()); + const body = renderReleasePrBody("1.0.0-beta.0", "integration"); expect(workflow).not.toContain("npm run build:lib:types"); expect(workflow).not.toMatch(/git add[^\n]*_types/); expect(workflow).toMatch(/git add[^\n]*package-lock\.json/); - expect(body.status, body.stderr).toBe(0); - expect(body.stdout).toContain("locked Commonlib package version"); + expect(body).toContain("locked Commonlib package version"); }); it("reruns the version lifecycle when the integration branch already selects the release version", () => { @@ -183,36 +182,56 @@ describe("release workflow", () => { expect(workflow).not.toContain("latest stable release"); }); - it("keeps the release PR in draft until BRAT validation", () => { - const prerelease = runNode( - releasePrBodyScript, - ["1.0.0-beta.0", "common-library-package-boundary"], - makeTemporaryDirectory() - ); + it("keeps an immutable pre-release out of its base branch after BRAT validation", () => { + const prerelease = renderReleasePrBody("1.0.0-rc.0", "common-library-package-boundary"); - expect(prerelease.status, prerelease.stderr).toBe(0); - expect(prerelease.stdout).toContain("Merge intentionally on hold"); - expect(prerelease.stdout).toContain("Self-hosted LiveSync `1.0.0-beta.0`"); - expect(prerelease.stdout).toContain("leave `common-library-package-boundary` unchanged"); - expect(prerelease.stdout).toContain("prerelease=true"); - expect(prerelease.stdout).toContain( + expect(prerelease).toContain("Merge intentionally on hold"); + expect(prerelease).toContain("Self-hosted LiveSync `1.0.0-rc.0`"); + expect(prerelease).toContain("leave `common-library-package-boundary` unchanged"); + expect(prerelease).toContain("prerelease=true"); + expect(prerelease).toContain( "Publish the GitHub Release as a pre-release without replacing the latest stable release" ); - expect(prerelease.stdout).toContain("Validate the exact published release with BRAT"); - expect(prerelease.stdout).toContain( - "Mark this pull request ready and merge it into `common-library-package-boundary` with a merge commit" - ); + expect(prerelease).toContain("Validate the exact published release with BRAT"); + expect(prerelease).toContain("Keep this pre-release pull request unmerged"); + expect(prerelease).toContain("close it only through a separate maintainer action"); + expect(prerelease).not.toContain("Mark this pull request ready and merge it"); }); - it("keeps stable release instructions distinct from pre-release instructions", () => { - const stable = runNode(releasePrBodyScript, ["1.0.0", "main"], makeTemporaryDirectory()); + it("publishes a stable version initially as a GitHub pre-release for BRAT validation", () => { + const stable = renderReleasePrBody("1.0.0", "main"); - expect(stable.status, stable.stderr).toBe(0); - expect(stable.stdout).toContain("prerelease=false"); - expect(stable.stdout).toContain( - "Publish the GitHub Release as the latest stable release while keeping this pull request in draft" + expect(stable).toContain("prerelease=true"); + expect(stable).toContain("publish_cli=false"); + expect(stable).toContain( + "Publish the GitHub Release initially as a pre-release without replacing the latest stable release" + ); + expect(stable).toContain( + "Remove the pre-release designation and make this exact release the latest stable release" + ); + expect(stable).toContain( + "Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate" + ); + expect(stable).toContain("Mark this pull request ready and merge it into `main` with a merge commit"); + expect(stable).not.toContain("prerelease=false"); + }); + + it("summarises immutable pre-releases separately from stable versions awaiting promotion", () => { + const workflow = readFileSync(finaliseReleaseWorkflow, "utf8"); + + expect(workflow).toContain('if [[ "${VERSION}" == *-* ]]; then'); + expect(workflow).toContain( + "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action." + ); + expect(workflow).toContain( + "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before merging the release pull request." + ); + expect(workflow).toContain( + 'if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then' + ); + expect(workflow).toContain( + "A stable version staged as a pre-release must use publish_cli=false so that the CLI latest and major-minor image tags do not advance before BRAT validation." ); - expect(stable.stdout).not.toContain("as a pre-release without replacing"); }); it("dispatches the plug-in workflow and lets the CLI tag trigger its own workflow", () => {