Compare commits

..

3 Commits

Author SHA1 Message Date
vorotamoroz 3efc72bab9 limit permission 2026-07-08 03:24:33 +00:00
vorotamoroz b8a4c824e2 improve release flow 2026-07-08 03:21:40 +00:00
vorotamoroz 6a9918677f Merge pull request #996 from vrtmrz/0_25_80
releasing 0.25.80
2026-07-07 21:21:34 +09:00
7 changed files with 400 additions and 4 deletions
+82
View File
@@ -0,0 +1,82 @@
name: Finalise Release Tags
on:
workflow_dispatch:
inputs:
version:
description: Release version, for example 0.25.81
required: true
type: string
release_branch:
description: Release PR branch. Defaults to the version with dots replaced by underscores.
required: false
type: string
jobs:
finalise:
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
steps:
- name: Resolve release branch
id: branch
env:
VERSION: ${{ inputs.version }}
RELEASE_BRANCH_INPUT: ${{ inputs.release_branch }}
run: |
set -euo pipefail
BRANCH="${RELEASE_BRANCH_INPUT}"
if [[ -z "${BRANCH}" ]]; then
BRANCH="${VERSION//./_}"
fi
echo "name=${BRANCH}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.branch.outputs.name }}
fetch-depth: 0
submodules: recursive
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
- name: Validate release head
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
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
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git tag "${VERSION}"
git tag "${VERSION}-cli"
git push origin "${VERSION}" "${VERSION}-cli"
- name: Summarise next steps
env:
VERSION: ${{ inputs.version }}
run: |
{
echo "Created tags \`${VERSION}\` and \`${VERSION}-cli\`."
echo ""
echo "The plug-in release workflow is triggered by the \`${VERSION}\` tag and creates a draft release by default."
echo "The CLI Docker workflow is triggered by the \`${VERSION}-cli\` tag and publishes the version, major-minor, latest, and SHA-qualified image tags."
echo ""
echo "To create a pre-release instead of the default draft flow, run \`Release Obsidian Plugin\` manually with \`tag=${VERSION}\`, \`draft=false\`, and \`prerelease=true\`."
} >> "$GITHUB_STEP_SUMMARY"
+102
View File
@@ -0,0 +1,102 @@
name: Prepare Release PR
on:
workflow_dispatch:
inputs:
version:
description: Release version, for example 0.25.81
required: true
type: string
base_branch:
description: Base branch for the release PR
required: false
type: string
default: main
release_branch:
description: Release branch name. Defaults to the version with dots replaced by underscores.
required: false
type: string
allow_empty_updates:
description: Allow an empty Unreleased section
required: false
type: boolean
default: false
jobs:
prepare:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.base_branch }}
fetch-depth: 0
submodules: recursive
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
cache: npm
- name: Install dependencies
run: npm ci
- name: Prepare release changes
id: prepare
env:
VERSION: ${{ inputs.version }}
RELEASE_BRANCH_INPUT: ${{ inputs.release_branch }}
ALLOW_EMPTY_UPDATES: ${{ inputs.allow_empty_updates }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
BRANCH="${RELEASE_BRANCH_INPUT}"
if [[ -z "${BRANCH}" ]]; then
BRANCH="${VERSION//./_}"
fi
if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then
echo "Release branch already exists: ${BRANCH}" >&2
exit 1
fi
git switch -c "${BRANCH}"
npm version "${VERSION}" --no-git-tag-version
node utils/release-notes.mjs prepare "${VERSION}"
npm run pretty:json
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
git diff --cached --check
git commit -m "Releasing ${VERSION}"
git push --set-upstream origin "${BRANCH}"
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
- name: Create draft release PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
BASE_BRANCH: ${{ inputs.base_branch }}
RELEASE_BRANCH: ${{ steps.prepare.outputs.branch }}
run: |
cat > /tmp/release-pr-body.md <<EOF
## Release checklist
- [ ] Review and polish \`updates.md\`
- [ ] Confirm \`manifest.json\`, \`versions.json\`, and workspace package versions
- [ ] Confirm CI has passed
- [ ] Run the finalise release workflow after the PR head is fixed
- [ ] Merge this PR after the draft or pre-release has been created
EOF
gh pr create \
--base "${BASE_BRANCH}" \
--head "${RELEASE_BRANCH}" \
--draft \
--title "Releasing ${VERSION}" \
--body-file /tmp/release-pr-body.md
+31 -2
View File
@@ -6,10 +6,26 @@ on:
- '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10
- '!*-cli' # Exclude command-line interface tags
workflow_dispatch:
inputs:
tag:
description: Release tag to build
required: true
type: string
draft:
description: Create the GitHub Release as a draft
required: false
type: boolean
default: true
prerelease:
description: Mark the GitHub Release as a pre-release
required: false
type: boolean
default: false
jobs:
build:
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
id-token: write
@@ -19,6 +35,7 @@ jobs:
with:
fetch-depth: 0 # otherwise, you will failed to push refs to dest repo
submodules: recursive
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- name: Use Node.js
uses: actions/setup-node@v4
with:
@@ -27,7 +44,18 @@ jobs:
- name: Get Version
id: version
run: |
echo "tag=$(git describe --abbrev=0 --tags)" >> $GITHUB_OUTPUT
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"
fi
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "draft=${DRAFT}" >> $GITHUB_OUTPUT
echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT
# Build the plugin
- name: Build
id: build
@@ -58,4 +86,5 @@ jobs:
styles.css
name: ${{ steps.version.outputs.tag }}
tag_name: ${{ steps.version.outputs.tag }}
draft: true
draft: ${{ steps.version.outputs.draft }}
prerelease: ${{ steps.version.outputs.prerelease }}
+41
View File
@@ -252,6 +252,47 @@ export class ModuleExample extends AbstractObsidianModule {
In short, the situation remains unchanged for me, but it means you all become a little safer. Thank you for your understanding!
## Release Notes
- Keep the top section of `updates.md` as `## Unreleased` during normal development.
- When opening a feature or fix PR, update `## Unreleased` in the same PR if the change is user-facing.
- Add only user-facing changes that help users understand what they gain, what has changed, or what they may need to do after updating.
- Avoid listing purely internal refactors, maintenance chores, generated-file changes, and dependency updates unless they affect users; group and label them when they are included.
- When preparing a release, replace `## Unreleased` with the target version heading (for example, `## 0.25.81`) and add a fresh empty `## Unreleased` section above it for the next cycle.
- Review and polish the released section in the release PR before tagging, because the content is embedded into the plug-in and may be reused as the GitHub Release notes.
## Release Workflow
This workflow is for maintainers. Contributors should update `## Unreleased` for user-facing feature or fix PRs, but do not need to run the release workflows.
The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `release` GitHub Environment. Configure Environment protection in the repository settings so tag creation and release publication require maintainer approval.
- Run the `Prepare Release PR` workflow with the target version. It creates the release branch, updates versions, 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. It validates the release branch and pushes both the plug-in tag (for example, `0.25.81`) and the CLI tag (for example, `0.25.81-cli`) to the same commit.
- The plug-in tag triggers the release workflow and creates a draft GitHub Release by default. The CLI tag triggers the Docker workflow and publishes the fixed version tag, the major-minor moving tag (for example, `0.25-cli`), `latest`, and the SHA-qualified tag.
- If a pre-release is needed, run the `Release Obsidian Plugin` workflow manually with the target tag, `draft=false`, and `prerelease=true`.
### Release Cheat Sheet
1. Before starting, add user-facing notes under `## Unreleased` in `updates.md`.
2. Run `Prepare Release PR` from GitHub Actions.
- `version`: the target version, for example `0.25.81`.
- `base_branch`: normally `main`.
- `release_branch`: leave blank to use the default branch name, for example `0_25_81`.
- `allow_empty_updates`: leave disabled unless the release intentionally has no user-facing notes.
3. Review the generated draft PR.
- Polish `updates.md`.
- Confirm `package.json`, `manifest.json`, `versions.json`, and workspace package versions.
- Confirm that `manifest.json` has the intended `minAppVersion`.
- Wait for the necessary CI checks.
4. When the PR head is fixed, run `Finalise Release Tags`.
- `version`: the same target version.
- `release_branch`: leave blank unless the release branch used a custom name.
5. Check the generated draft GitHub Release for the plug-in tag.
6. Check the CLI Docker workflow started from the `*-cli` tag.
7. Publish the draft GitHub Release when ready, then merge the release PR into `main`.
8. If the release should be a pre-release instead of a draft release, run `Release Obsidian Plugin` manually with the target `tag`, `draft=false`, and `prerelease=true`.
## Contribution Guidelines
- Follow existing code style and conventions
+2
View File
@@ -3,6 +3,8 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
## Unreleased
## 0.25.80
7th July, 2026
+140
View File
@@ -0,0 +1,140 @@
import { readFileSync, writeFileSync } from "fs";
const updatesPath = "updates.md";
// Utility used by the release workflows to rotate and validate `updates.md`.
// It intentionally keeps the Markdown format simple: top-level `##` headings
// are treated as release boundaries, and only one `## Unreleased` section is
// allowed.
function fail(message) {
console.error(message);
process.exit(1);
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function assertVersion(version) {
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
fail(`Invalid release version: ${version}`);
}
}
function formatReleaseDate(date = new Date()) {
const day = date.getUTCDate();
const suffix = day % 10 === 1 && day !== 11 ? "st" : day % 10 === 2 && day !== 12 ? "nd" : day % 10 === 3 && day !== 13 ? "rd" : "th";
const month = new Intl.DateTimeFormat("en-GB", { month: "long", timeZone: "UTC" }).format(date);
const year = date.getUTCFullYear();
return `${day}${suffix} ${month}, ${year}`;
}
function headingPattern(heading) {
return new RegExp(`^## ${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
}
// Return a `##` section body without interpreting lower-level headings.
function findSection(markdown, heading) {
const pattern = headingPattern(heading);
const match = pattern.exec(markdown);
if (!match) return undefined;
const headingStart = match.index;
const bodyStart = match.index + match[0].length;
const rest = markdown.slice(bodyStart);
const next = /^## .+$/m.exec(rest);
const end = next ? bodyStart + next.index : markdown.length;
return {
headingStart,
bodyStart,
end,
body: markdown.slice(bodyStart, end),
};
}
function assertSingleUnreleased(markdown) {
const matches = markdown.match(/^## Unreleased\s*$/gm) || [];
if (matches.length !== 1) {
fail(`Expected exactly one '## Unreleased' section in ${updatesPath}, found ${matches.length}.`);
}
}
function prepare(version) {
assertVersion(version);
const markdown = readFileSync(updatesPath, "utf8");
assertSingleUnreleased(markdown);
if (headingPattern(version).test(markdown)) {
fail(`Release notes for ${version} already exist in ${updatesPath}.`);
}
const unreleased = findSection(markdown, "Unreleased");
if (!unreleased) fail(`Could not find '## Unreleased' in ${updatesPath}.`);
const allowEmpty = process.env.ALLOW_EMPTY_UPDATES === "true";
if (!allowEmpty && unreleased.body.trim() === "") {
fail(`The '## Unreleased' section is empty. Set ALLOW_EMPTY_UPDATES=true if this is intentional.`);
}
// Keep a fresh empty Unreleased section above the newly dated release notes.
const releasedBody = `${unreleased.body.trim()}\n\n`;
const releaseDate = process.env.RELEASE_DATE || formatReleaseDate();
const replacement = `## Unreleased\n\n## ${version}\n\n${releaseDate}\n\n${releasedBody}`;
const nextMarkdown = markdown.slice(0, unreleased.headingStart) + replacement + markdown.slice(unreleased.end);
writeFileSync(updatesPath, nextMarkdown, "utf8");
}
function validate(version) {
assertVersion(version);
const rootPackage = readJson("package.json");
const manifest = readJson("manifest.json");
if (rootPackage.version !== version) {
fail(`package.json version is ${rootPackage.version}, expected ${version}.`);
}
if (manifest.version !== version) {
fail(`manifest.json version is ${manifest.version}, expected ${version}.`);
}
for (const workspace of ["cli", "webpeer", "webapp"]) {
const workspacePackage = readJson(`src/apps/${workspace}/package.json`);
const expected = `${version}-${workspace}`;
if (workspacePackage.version !== expected) {
fail(`src/apps/${workspace}/package.json version is ${workspacePackage.version}, expected ${expected}.`);
}
}
const versions = readJson("versions.json");
if (versions[version] !== manifest.minAppVersion) {
fail(`versions.json does not map ${version} to manifest minAppVersion ${manifest.minAppVersion}.`);
}
const markdown = readFileSync(updatesPath, "utf8");
assertSingleUnreleased(markdown);
const releaseSection = findSection(markdown, version);
if (!releaseSection) {
fail(`Could not find '## ${version}' in ${updatesPath}.`);
}
if (releaseSection.body.trim() === "") {
fail(`The release notes for ${version} are empty.`);
}
if (/\b(?:TODO|WIP)\b/i.test(releaseSection.body)) {
fail(`The release notes for ${version} still contain TODO or WIP markers.`);
}
}
const [command, version] = process.argv.slice(2);
if (!command || !version) {
fail("Usage: node utils/release-notes.mjs <prepare|validate> <version>");
}
if (command === "prepare") {
prepare(version);
} else if (command === "validate") {
validate(version);
} else {
fail(`Unknown command: ${command}`);
}
+2 -2
View File
@@ -11,7 +11,7 @@ writeFileSync("manifest.json", JSON.stringify(manifest, null, 4));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
if (!(targetVersion in versions)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, 4));
}
}