diff --git a/.github/workflows/finalise-release.yml b/.github/workflows/finalise-release.yml index 9f07c7bc..5e4d5dbf 100644 --- a/.github/workflows/finalise-release.yml +++ b/.github/workflows/finalise-release.yml @@ -60,24 +60,16 @@ jobs: exit 1 fi node utils/release-notes.mjs validate "${VERSION}" - git fetch --tags --force - if git rev-parse --verify --quiet "refs/tags/${VERSION}" >/dev/null; then - echo "Tag already exists: ${VERSION}" >&2 - exit 1 - fi - if git rev-parse --verify --quiet "refs/tags/${VERSION}-cli" >/dev/null; then - echo "Tag already exists: ${VERSION}-cli" >&2 - exit 1 - fi - - name: Create and push release tags + - name: Ensure and push release tags env: VERSION: ${{ inputs.version }} + EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }} run: | set -euo pipefail - git tag "${VERSION}" - git tag "${VERSION}-cli" - git push origin "${VERSION}" "${VERSION}-cli" + git fetch --tags --force + node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}" + git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli" - name: Dispatch release workflows env: @@ -100,7 +92,7 @@ jobs: VERSION: ${{ inputs.version }} run: | { - echo "Created tags \`${VERSION}\` and \`${VERSION}-cli\`." + echo "Ensured tags \`${VERSION}\` and \`${VERSION}-cli\` point to the reviewed release commit." echo "" echo "Dispatched the plug-in release workflow for \`${VERSION}\`. After approval for the release environment, it creates a draft GitHub Release." echo "Dispatched the CLI Docker workflow for \`${VERSION}-cli\`. It publishes the version, major-minor, latest, and SHA-qualified image tags." diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index ca74d6c7..f9d4908f 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -78,7 +78,6 @@ jobs: git switch -c "${BRANCH}" npm version "${VERSION}" --no-git-tag-version node utils/release-notes.mjs prepare "${VERSION}" - npm run pretty:json npm run build:lib:types git add package.json package-lock.json manifest.json versions.json updates.md src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json _types diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f92a573b..d39e5ca1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,5 @@ name: Release Obsidian Plugin on: - push: - # Sequence of patterns matched against refs/tags - tags: - - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 - - '!*-cli' # Exclude command-line interface tags workflow_dispatch: inputs: tag: @@ -33,29 +28,25 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 # otherwise, you will failed to push refs to dest repo + fetch-depth: 0 submodules: recursive - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + ref: ${{ inputs.tag }} - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '24.x' # You might need to adjust this value to your own version - # Get the version number and put it in a variable - - name: Get Version - id: version + node-version: '24.x' + - name: Validate release + env: + TAG: ${{ inputs.tag }} run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - TAG="${{ inputs.tag }}" - DRAFT="${{ inputs.draft }}" - PRERELEASE="${{ inputs.prerelease }}" - else - TAG="${GITHUB_REF_NAME}" - DRAFT="true" - PRERELEASE="false" + set -euo pipefail + node utils/release-notes.mjs validate "${TAG}" + HEAD_SHA="$(git rev-parse HEAD)" + TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")" + if [[ "${HEAD_SHA}" != "${TAG_SHA}" ]]; then + echo "Checked-out commit is ${HEAD_SHA}, but tag ${TAG} points to ${TAG_SHA}." >&2 + exit 1 fi - echo "tag=${TAG}" >> $GITHUB_OUTPUT - echo "draft=${DRAFT}" >> $GITHUB_OUTPUT - echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT # Build the plugin - name: Build id: build @@ -84,7 +75,7 @@ jobs: main.js manifest.json styles.css - name: ${{ steps.version.outputs.tag }} - tag_name: ${{ steps.version.outputs.tag }} - draft: ${{ steps.version.outputs.draft }} - prerelease: ${{ steps.version.outputs.prerelease }} + name: ${{ inputs.tag }} + tag_name: ${{ inputs.tag }} + draft: ${{ inputs.draft }} + prerelease: ${{ inputs.prerelease }} diff --git a/devs.md b/devs.md index d066bc60..38432cf7 100644 --- a/devs.md +++ b/devs.md @@ -274,7 +274,8 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel - Run the `Prepare Release PR` workflow with the target version. It creates the release branch, updates versions, regenerates the `_types` fallback definitions used by the community plug-in scan, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR. - 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, pushes both the plug-in tag (for example, `0.25.81`) and the CLI tag (for example, `0.25.81-cli`) to that commit, and explicitly dispatches the plug-in and CLI publishing workflows. An explicit dispatch is required because tags pushed with `GITHUB_TOKEN` do not trigger tag-push workflows. +- 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 both the plug-in tag (for example, `0.25.81`) and the CLI tag (for example, `0.25.81-cli`) point to that commit, and explicitly dispatches the plug-in and CLI publishing workflows. The workflow can be retried when existing tags already point to the reviewed commit, but stops if either 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. Confirm that the CLI workflow has published the fixed version tag, the major-minor moving tag (for example, `0.25-cli`), `latest`, and the SHA-qualified tag. - Publish the draft GitHub Release as the latest stable release while keeping the release PR in draft and leaving `main` unchanged. Record the state in the PR with: 'Release `` has been published as the latest stable release. This pull request intentionally remains in draft, and `main` has not yet been updated. Merge is on hold until BRAT validation is complete.' - Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release. diff --git a/utils/release-notes.mjs b/utils/release-notes.mjs index 3e4d9857..8c917ae6 100644 --- a/utils/release-notes.mjs +++ b/utils/release-notes.mjs @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync } from "fs"; +import { readFileSync, writeFileSync, writeSync } from "fs"; const updatesPath = "updates.md"; @@ -8,7 +8,7 @@ const updatesPath = "updates.md"; // allowed. function fail(message) { - console.error(message); + writeSync(process.stderr.fd, `${message}\n`); process.exit(1); } diff --git a/utils/release-process.unit.spec.ts b/utils/release-process.unit.spec.ts index 859abd01..94c9c125 100644 --- a/utils/release-process.unit.spec.ts +++ b/utils/release-process.unit.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { ensureTags } from "./release-tags.mjs"; const releaseNotesScript = fileURLToPath(new URL("./release-notes.mjs", import.meta.url)); const versionBumpScript = @@ -11,6 +12,7 @@ const versionBumpScript = const workspaceUpdateScript = fileURLToPath(new URL("../update-workspaces.mjs", import.meta.url)); const prepareReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/prepare-release.yml", import.meta.url)); const finaliseReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/finalise-release.yml", import.meta.url)); +const releaseWorkflow = fileURLToPath(new URL("../.github/workflows/release.yml", import.meta.url)); const temporaryDirectories: string[] = []; afterEach(() => { @@ -39,6 +41,28 @@ function runNode(script: string, args: string[], cwd: string, env: Record = {}) { + const tags = new Map(Object.entries(initialTags)); + const git = (args: string[], allowMissing = false): string | undefined => { + if (args[0] === "rev-parse") { + const revision = args.at(-1); + if (revision === `${expectedRevision}^{commit}`) return expectedRevision; + const tagMatch = revision?.match(/^refs\/tags\/(.+)\^\{commit\}$/); + if (tagMatch) { + const commit = tags.get(tagMatch[1]); + if (commit !== undefined) return commit; + if (allowMissing) return undefined; + } + } + if (args[0] === "tag" && args.length === 3) { + tags.set(args[1], args[2]); + return ""; + } + throw new Error(`Unexpected git command: ${args.join(" ")}`); + }; + return { git, tags }; +} + function createReleaseFixture(version = "0.25.81"): string { const directory = makeTemporaryDirectory(); writeJson(directory, "package.json", { version }); @@ -137,10 +161,50 @@ describe("release workflow", () => { const workflow = readFileSync(finaliseReleaseWorkflow, "utf8"); expect(workflow).toContain("actions: write"); + expect(workflow).toContain('node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}"'); + expect(workflow).toContain('git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"'); + expect(workflow).not.toContain("Tag already exists"); expect(workflow).toContain("gh workflow run release.yml"); expect(workflow).toContain("gh workflow run cli-docker.yml"); expect(workflow).toContain("dry_run=false"); }); + + it("publishes only by explicit dispatch and validates the selected release", () => { + const workflow = readFileSync(releaseWorkflow, "utf8"); + + expect(workflow).not.toMatch(/^\s+push:/m); + expect(workflow).toContain("ref: ${{ inputs.tag }}"); + expect(workflow).toContain('node utils/release-notes.mjs validate "${TAG}"'); + expect(workflow).toContain('TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")"'); + expect(workflow).not.toContain("Get Version"); + }); +}); + +describe("release tags", () => { + it("creates missing tags and accepts matching tags on retry", () => { + const head = "a".repeat(40); + const { git, tags } = createTagGit(head); + const messages: string[] = []; + + ensureTags("0.25.84", head, git, (message) => messages.push(message)); + expect(tags.get("0.25.84")).toBe(head); + expect(tags.get("0.25.84-cli")).toBe(head); + + ensureTags("0.25.84", head, git, (message) => messages.push(message)); + expect(messages).toContain(`Tag 0.25.84 already points to the expected commit ${head}.`); + expect(messages).toContain(`Tag 0.25.84-cli already points to the expected commit ${head}.`); + }); + + it("rejects an existing release tag that points to another commit without creating missing tags", () => { + const previousHead = "b".repeat(40); + const expectedHead = "a".repeat(40); + const { git, tags } = createTagGit(expectedHead, { "0.25.84-cli": previousHead }); + + expect(() => ensureTags("0.25.84", expectedHead, git)).toThrow( + `Tag 0.25.84-cli points to ${previousHead}; expected ${expectedHead}.` + ); + expect(tags.has("0.25.84")).toBe(false); + }); }); describe("version bump", () => { diff --git a/utils/release-tags.mjs b/utils/release-tags.mjs new file mode 100644 index 00000000..739e75e0 --- /dev/null +++ b/utils/release-tags.mjs @@ -0,0 +1,73 @@ +import { spawnSync } from "node:child_process"; +import { writeSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +function fail(message) { + writeSync(process.stderr.fd, `${message}\n`); + process.exit(1); +} + +function assertVersion(version) { + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid release version: ${version}`); + } +} + +function git(args, allowMissing = false) { + const result = spawnSync("git", args, { encoding: "utf8" }); + if (allowMissing && result.status === 1 && result.stdout === "" && result.stderr === "") { + return undefined; + } + if (result.error) { + throw new Error(`Could not run git ${args.join(" ")}: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `git ${args.join(" ")} exited with status ${result.status}.`); + } + return result.stdout.trim(); +} + +function resolveCommit(revision, runGit) { + return runGit(["rev-parse", "--verify", `${revision}^{commit}`]); +} + +function resolveTag(tag, runGit) { + return runGit(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`], true); +} + +export function ensureTags(version, expectedRevision, runGit = git, log = console.log) { + assertVersion(version); + const expectedCommit = resolveCommit(expectedRevision, runGit); + const tags = [version, `${version}-cli`]; + const existing = tags.map((tag) => ({ tag, commit: resolveTag(tag, runGit) })); + + for (const { tag, commit } of existing) { + if (commit !== undefined && commit !== expectedCommit) { + throw new Error(`Tag ${tag} points to ${commit}; expected ${expectedCommit}.`); + } + } + + for (const { tag, commit } of existing) { + if (commit === undefined) { + runGit(["tag", tag, expectedCommit]); + log(`Created tag ${tag} at ${expectedCommit}.`); + } else { + log(`Tag ${tag} already points to the expected commit ${expectedCommit}.`); + } + } +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; +if (isMain) { + const [command, version, expectedRevision] = process.argv.slice(2); + if (command !== "ensure" || !version || !expectedRevision) { + fail("Usage: node utils/release-tags.mjs ensure "); + } + + try { + ensureTags(version, expectedRevision); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } +}