mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-24 11:37:07 +00:00
Harden release workflow validation and retries
This commit is contained in:
@@ -60,24 +60,16 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
node utils/release-notes.mjs validate "${VERSION}"
|
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:
|
env:
|
||||||
VERSION: ${{ inputs.version }}
|
VERSION: ${{ inputs.version }}
|
||||||
|
EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
git tag "${VERSION}"
|
git fetch --tags --force
|
||||||
git tag "${VERSION}-cli"
|
node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}"
|
||||||
git push origin "${VERSION}" "${VERSION}-cli"
|
git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"
|
||||||
|
|
||||||
- name: Dispatch release workflows
|
- name: Dispatch release workflows
|
||||||
env:
|
env:
|
||||||
@@ -100,7 +92,7 @@ jobs:
|
|||||||
VERSION: ${{ inputs.version }}
|
VERSION: ${{ inputs.version }}
|
||||||
run: |
|
run: |
|
||||||
{
|
{
|
||||||
echo "Created tags \`${VERSION}\` and \`${VERSION}-cli\`."
|
echo "Ensured tags \`${VERSION}\` and \`${VERSION}-cli\` point to the reviewed release commit."
|
||||||
echo ""
|
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 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."
|
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}"
|
git switch -c "${BRANCH}"
|
||||||
npm version "${VERSION}" --no-git-tag-version
|
npm version "${VERSION}" --no-git-tag-version
|
||||||
node utils/release-notes.mjs prepare "${VERSION}"
|
node utils/release-notes.mjs prepare "${VERSION}"
|
||||||
npm run pretty:json
|
|
||||||
npm run build:lib:types
|
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
|
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
|
name: Release Obsidian Plugin
|
||||||
on:
|
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:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
@@ -33,29 +28,25 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # otherwise, you will failed to push refs to dest repo
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
ref: ${{ inputs.tag }}
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '24.x' # You might need to adjust this value to your own version
|
node-version: '24.x'
|
||||||
# Get the version number and put it in a variable
|
- name: Validate release
|
||||||
- name: Get Version
|
env:
|
||||||
id: version
|
TAG: ${{ inputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
set -euo pipefail
|
||||||
TAG="${{ inputs.tag }}"
|
node utils/release-notes.mjs validate "${TAG}"
|
||||||
DRAFT="${{ inputs.draft }}"
|
HEAD_SHA="$(git rev-parse HEAD)"
|
||||||
PRERELEASE="${{ inputs.prerelease }}"
|
TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")"
|
||||||
else
|
if [[ "${HEAD_SHA}" != "${TAG_SHA}" ]]; then
|
||||||
TAG="${GITHUB_REF_NAME}"
|
echo "Checked-out commit is ${HEAD_SHA}, but tag ${TAG} points to ${TAG_SHA}." >&2
|
||||||
DRAFT="true"
|
exit 1
|
||||||
PRERELEASE="false"
|
|
||||||
fi
|
fi
|
||||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
|
||||||
echo "draft=${DRAFT}" >> $GITHUB_OUTPUT
|
|
||||||
echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT
|
|
||||||
# Build the plugin
|
# Build the plugin
|
||||||
- name: Build
|
- name: Build
|
||||||
id: build
|
id: build
|
||||||
@@ -84,7 +75,7 @@ jobs:
|
|||||||
main.js
|
main.js
|
||||||
manifest.json
|
manifest.json
|
||||||
styles.css
|
styles.css
|
||||||
name: ${{ steps.version.outputs.tag }}
|
name: ${{ inputs.tag }}
|
||||||
tag_name: ${{ steps.version.outputs.tag }}
|
tag_name: ${{ inputs.tag }}
|
||||||
draft: ${{ steps.version.outputs.draft }}
|
draft: ${{ inputs.draft }}
|
||||||
prerelease: ${{ steps.version.outputs.prerelease }}
|
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.
|
- 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`.
|
- 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.
|
- 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.'
|
- 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.
|
- 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";
|
const updatesPath = "updates.md";
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ const updatesPath = "updates.md";
|
|||||||
// allowed.
|
// allowed.
|
||||||
|
|
||||||
function fail(message) {
|
function fail(message) {
|
||||||
console.error(message);
|
writeSync(process.stderr.fd, `${message}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { ensureTags } from "./release-tags.mjs";
|
||||||
|
|
||||||
const releaseNotesScript = fileURLToPath(new URL("./release-notes.mjs", import.meta.url));
|
const releaseNotesScript = fileURLToPath(new URL("./release-notes.mjs", import.meta.url));
|
||||||
const versionBumpScript =
|
const versionBumpScript =
|
||||||
@@ -11,6 +12,7 @@ const versionBumpScript =
|
|||||||
const workspaceUpdateScript = fileURLToPath(new URL("../update-workspaces.mjs", import.meta.url));
|
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 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 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[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
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 {
|
function createReleaseFixture(version = "0.25.81"): string {
|
||||||
const directory = makeTemporaryDirectory();
|
const directory = makeTemporaryDirectory();
|
||||||
writeJson(directory, "package.json", { version });
|
writeJson(directory, "package.json", { version });
|
||||||
@@ -137,10 +161,50 @@ describe("release workflow", () => {
|
|||||||
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
|
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
|
||||||
|
|
||||||
expect(workflow).toContain("actions: write");
|
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 release.yml");
|
||||||
expect(workflow).toContain("gh workflow run cli-docker.yml");
|
expect(workflow).toContain("gh workflow run cli-docker.yml");
|
||||||
expect(workflow).toContain("dry_run=false");
|
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", () => {
|
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