mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-17 01:56:00 +00:00
Harden release workflow validation and retries
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 `<version>` 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string
|
||||
});
|
||||
}
|
||||
|
||||
function createTagGit(expectedRevision: string, initialTags: Record<string, string> = {}) {
|
||||
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", () => {
|
||||
|
||||
@@ -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 <version> <expected-commit>");
|
||||
}
|
||||
|
||||
try {
|
||||
ensureTags(version, expectedRevision);
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user