Skip to content

Commit ce10328

Browse files
committed
build: automate the release process
1 parent b7ea9e4 commit ce10328

7 files changed

Lines changed: 418 additions & 15 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
name: Finalise Release Tags
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: Release version, for example 0.18.18
8+
required: true
9+
type: string
10+
release_branch:
11+
description: Release PR branch. Defaults to release/<version>.
12+
required: false
13+
type: string
14+
expected_head_sha:
15+
description: Full head commit SHA reviewed in the release PR
16+
required: true
17+
type: string
18+
19+
jobs:
20+
finalise:
21+
runs-on: ubuntu-latest
22+
environment: release
23+
permissions:
24+
actions: write
25+
contents: write
26+
27+
steps:
28+
- name: Resolve release branch
29+
id: branch
30+
env:
31+
VERSION: ${{ inputs.version }}
32+
RELEASE_BRANCH_INPUT: ${{ inputs.release_branch }}
33+
run: |
34+
set -euo pipefail
35+
BRANCH="${RELEASE_BRANCH_INPUT}"
36+
if [[ -z "${BRANCH}" ]]; then
37+
BRANCH="release/${VERSION}"
38+
fi
39+
git check-ref-format --branch "${BRANCH}"
40+
echo "name=${BRANCH}" >> "${GITHUB_OUTPUT}"
41+
42+
- uses: actions/checkout@v4
43+
with:
44+
ref: ${{ steps.branch.outputs.name }}
45+
fetch-depth: 0
46+
47+
- name: Validate release head
48+
env:
49+
VERSION: ${{ inputs.version }}
50+
EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }}
51+
run: |
52+
set -euo pipefail
53+
ACTUAL_HEAD_SHA="$(git rev-parse HEAD)"
54+
if [[ "${ACTUAL_HEAD_SHA}" != "${EXPECTED_HEAD_SHA}" ]]; then
55+
echo "Release branch head is ${ACTUAL_HEAD_SHA}, expected ${EXPECTED_HEAD_SHA}." >&2
56+
exit 1
57+
fi
58+
node release-process.mjs validate "${VERSION}"
59+
git fetch --tags --force
60+
if git rev-parse --verify --quiet "refs/tags/${VERSION}" >/dev/null; then
61+
echo "Release tag already exists: ${VERSION}" >&2
62+
exit 1
63+
fi
64+
65+
- name: Create and push release tag
66+
env:
67+
VERSION: ${{ inputs.version }}
68+
run: |
69+
set -euo pipefail
70+
git tag "${VERSION}"
71+
git push origin "${VERSION}"
72+
73+
- name: Dispatch release workflow
74+
env:
75+
GH_TOKEN: ${{ github.token }}
76+
VERSION: ${{ inputs.version }}
77+
run: |
78+
set -euo pipefail
79+
gh workflow run release.yml \
80+
--ref "${VERSION}" \
81+
--field tag="${VERSION}" \
82+
--field draft=true \
83+
--field prerelease=false
84+
85+
- name: Summarise next steps
86+
env:
87+
VERSION: ${{ inputs.version }}
88+
run: |
89+
{
90+
echo "Created tag \`${VERSION}\` and explicitly dispatched the release workflow."
91+
echo ""
92+
echo "Approve the release environment, inspect and publish the draft GitHub Release, then validate it with BRAT."
93+
echo "Keep the release pull request in draft until BRAT succeeds, then merge it with a merge commit."
94+
} >> "${GITHUB_STEP_SUMMARY}"
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
name: Prepare Release PR
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: Release version, for example 0.18.18
8+
required: true
9+
type: string
10+
release_branch:
11+
description: Release branch name. Defaults to release/<version>.
12+
required: false
13+
type: string
14+
15+
concurrency:
16+
group: prepare-release
17+
cancel-in-progress: false
18+
19+
jobs:
20+
prepare:
21+
runs-on: ubuntu-latest
22+
permissions:
23+
actions: write
24+
contents: write
25+
pull-requests: write
26+
27+
steps:
28+
- uses: actions/checkout@v4
29+
with:
30+
ref: main
31+
fetch-depth: 0
32+
33+
- name: Use Node.js
34+
uses: actions/setup-node@v4
35+
with:
36+
node-version: 24.x
37+
cache: npm
38+
39+
- name: Install dependencies
40+
run: npm ci
41+
42+
- name: Prepare release changes
43+
id: prepare
44+
env:
45+
VERSION: ${{ inputs.version }}
46+
RELEASE_BRANCH_INPUT: ${{ inputs.release_branch }}
47+
run: |
48+
set -euo pipefail
49+
node release-process.mjs input "${VERSION}"
50+
51+
BRANCH="${RELEASE_BRANCH_INPUT}"
52+
if [[ -z "${BRANCH}" ]]; then
53+
BRANCH="release/${VERSION}"
54+
fi
55+
git check-ref-format --branch "${BRANCH}"
56+
57+
if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then
58+
echo "Release branch already exists: ${BRANCH}" >&2
59+
exit 1
60+
fi
61+
git fetch --tags --force
62+
if git rev-parse --verify --quiet "refs/tags/${VERSION}" >/dev/null; then
63+
echo "Release tag already exists: ${VERSION}" >&2
64+
exit 1
65+
fi
66+
67+
git config user.name "github-actions[bot]"
68+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
69+
git switch -c "${BRANCH}"
70+
npm version "${VERSION}" --no-git-tag-version
71+
node release-process.mjs prepare "${VERSION}"
72+
npm run check
73+
npm run build
74+
npm run check:e2e:obsidian
75+
76+
RELEASE_FILES=(package.json package-lock.json manifest.json versions.json)
77+
if [[ -f updates.md ]]; then
78+
RELEASE_FILES+=(updates.md)
79+
fi
80+
git add "${RELEASE_FILES[@]}"
81+
git diff --cached --check
82+
if git diff --cached --quiet; then
83+
echo "Release preparation produced no tracked changes" >&2
84+
exit 1
85+
fi
86+
git commit -m "${VERSION}"
87+
git push --set-upstream origin "${BRANCH}"
88+
89+
echo "branch=${BRANCH}" >> "${GITHUB_OUTPUT}"
90+
91+
- name: Create draft release PR
92+
env:
93+
GH_TOKEN: ${{ github.token }}
94+
VERSION: ${{ inputs.version }}
95+
RELEASE_BRANCH: ${{ steps.prepare.outputs.branch }}
96+
run: |
97+
cat > /tmp/release-pr-body.md <<EOF
98+
> [!IMPORTANT]
99+
> **Merge intentionally on hold**
100+
>
101+
> Keep this pull request in draft, and leave `main` on the previous release, until the published build has passed BRAT validation.
102+
103+
## Release checklist
104+
105+
- [ ] Review and polish `updates.md`
106+
- [ ] Confirm `package.json`, `manifest.json`, and `versions.json` use `${VERSION}`
107+
- [ ] Confirm CI has passed
108+
- [ ] Run `Finalise Release Tags` with this pull request's fixed head SHA
109+
- [ ] Approve `Finalise Release Tags` for the `release` environment to create the fixed tag
110+
- [ ] Approve `Release Obsidian Plugin` for the `release` environment
111+
- [ ] Inspect the draft GitHub Release and its assets
112+
- [ ] Publish the GitHub Release as the latest stable release while keeping this pull request in draft
113+
- [ ] Validate the published release with BRAT
114+
- [ ] Mark this pull request ready and merge it with a merge commit
115+
116+
If BRAT validation fails, do not move the published tag. Keep this pull request in draft and prepare a new patch release.
117+
EOF
118+
119+
gh pr create \
120+
--base main \
121+
--head "${RELEASE_BRANCH}" \
122+
--draft \
123+
--title "${VERSION}" \
124+
--body-file /tmp/release-pr-body.md
125+
126+
- name: Dispatch CI for release branch
127+
env:
128+
GH_TOKEN: ${{ github.token }}
129+
RELEASE_BRANCH: ${{ steps.prepare.outputs.branch }}
130+
run: gh workflow run ci.yml --ref "${RELEASE_BRANCH}"

.github/workflows/release.yml

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,72 @@
11
name: Release Obsidian Plugin
22
on:
33
push:
4-
# Sequence of patterns matched against refs/tags
54
tags:
6-
- '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10
5+
- '*'
76
workflow_dispatch:
7+
inputs:
8+
tag:
9+
description: Release tag to build
10+
required: true
11+
type: string
12+
draft:
13+
description: Create the GitHub Release as a draft
14+
required: false
15+
type: boolean
16+
default: true
17+
prerelease:
18+
description: Mark the GitHub Release as a pre-release
19+
required: false
20+
type: boolean
21+
default: false
822

923
jobs:
1024
build:
1125
runs-on: ubuntu-latest
26+
environment: release
1227
permissions:
1328
contents: write
1429
id-token: write
1530
attestations: write
1631
steps:
1732
- uses: actions/checkout@v4
1833
with:
19-
fetch-depth: 0 # otherwise, you will failed to push refs to dest repo
34+
fetch-depth: 0
2035
submodules: recursive
36+
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
2137
- name: Use Node.js
2238
uses: actions/setup-node@v4
2339
with:
24-
node-version: '24.x' # You might need to adjust this value to your own version
25-
# Get the version number and put it in a variable
40+
node-version: '24.x'
2641
- name: Get Version
2742
id: version
2843
run: |
29-
echo "tag=$(git describe --abbrev=0 --tags)" >> $GITHUB_OUTPUT
30-
# Build the plugin
44+
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
45+
TAG="${{ inputs.tag }}"
46+
DRAFT="${{ inputs.draft }}"
47+
PRERELEASE="${{ inputs.prerelease }}"
48+
else
49+
TAG="${GITHUB_REF_NAME}"
50+
DRAFT="true"
51+
PRERELEASE="false"
52+
fi
53+
echo "tag=${TAG}" >> "${GITHUB_OUTPUT}"
54+
echo "draft=${DRAFT}" >> "${GITHUB_OUTPUT}"
55+
echo "prerelease=${PRERELEASE}" >> "${GITHUB_OUTPUT}"
56+
- name: Validate release files
57+
run: node release-process.mjs validate "${{ steps.version.outputs.tag }}"
3158
- name: Build
3259
id: build
3360
run: |
3461
npm ci
3562
npm run build --if-present
36-
# Attest
3763
- name: Attest Plugin Artifacts
3864
uses: actions/attest-build-provenance@v4
3965
with:
4066
subject-path: |
4167
main.js
4268
manifest.json
4369
styles.css
44-
# Package the required files into a zip
4570
- name: Package
4671
run: |
4772
mkdir ${{ github.event.repository.name }}
@@ -57,4 +82,5 @@ jobs:
5782
styles.css
5883
name: ${{ steps.version.outputs.tag }}
5984
tag_name: ${{ steps.version.outputs.tag }}
60-
draft: true
85+
draft: ${{ steps.version.outputs.draft }}
86+
prerelease: ${{ steps.version.outputs.prerelease }}

docs/devs.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,16 @@ npm run test:e2e:obsidian:new-note-template
5757
```
5858

5959
The real scenario uses the production UI and Vault adapters. It selects a template through Obsidian, creates real notes, and verifies both persisted template content and frontmatter tags; it never installs a scripted driver into the plug-in.
60+
61+
## Release process
62+
63+
The repository uses three manually gated workflows. Configure the GitHub `release` environment with a required reviewer before using them.
64+
65+
1. Run `Prepare Release PR` with the target version. It checks out `main`, runs the locked build and test gate, updates `package.json`, `manifest.json`, `versions.json`, and the `Unreleased` section in `updates.md`, pushes `release/<version>`, opens a draft pull request, and explicitly dispatches CI for the release branch. Explicit dispatch is required because branch and pull-request events created with `GITHUB_TOKEN` do not start another workflow.
66+
2. Review the release changes and release notes. Keep the pull request in draft and record its full head commit SHA.
67+
3. Run `Finalise Release Tags` with the version and reviewed SHA, then approve its `release` environment deployment. It validates the exact branch head, creates the tag, and explicitly dispatches `Release Obsidian Plugin`. Explicit dispatch is required because a tag pushed with `GITHUB_TOKEN` does not start a tag-push workflow.
68+
4. Approve the separate `Release Obsidian Plugin` deployment to the `release` environment, inspect the draft GitHub Release and its assets, then publish it as the latest stable release while leaving the release pull request in draft.
69+
5. Install the published build through BRAT and verify start-up, tree display, new-note templates, frontmatter tags, and any regression scenario relevant to the release.
70+
6. After BRAT succeeds, mark the pull request ready and merge it with a merge commit. A merge commit keeps the tagged release commit in `main` history.
71+
72+
If BRAT validation fails, do not move or replace the published tag. Leave the pull request in draft and prepare a new patch release. If the tag exists but publishing dispatch or build fails, rerun `Release Obsidian Plugin` manually for the existing tag instead of rerunning Finalise.

release-process.mjs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
2+
3+
const RELEASE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
4+
5+
function readJson(path) {
6+
return JSON.parse(readFileSync(path, "utf8"));
7+
}
8+
9+
function assertReleaseVersion(version) {
10+
if (!RELEASE_VERSION_PATTERN.test(version)) {
11+
throw new Error(`Invalid release version: ${version}`);
12+
}
13+
}
14+
15+
function prepareReleaseNotes(version) {
16+
if (!existsSync("updates.md")) return;
17+
const content = readFileSync("updates.md", "utf8");
18+
const targetHeading = `## ${version}`;
19+
if (content.startsWith(`${targetHeading}\n`) || content.startsWith(`${targetHeading}\r\n`)) return;
20+
if (!/^## Unreleased\r?$/m.test(content)) {
21+
throw new Error("updates.md has no '## Unreleased' section to prepare");
22+
}
23+
writeFileSync("updates.md", content.replace(/^## Unreleased\r?$/m, targetHeading));
24+
}
25+
26+
function validateVersionFiles(version) {
27+
assertReleaseVersion(version);
28+
const packageManifest = readJson("package.json");
29+
const pluginManifest = readJson("manifest.json");
30+
const versions = readJson("versions.json");
31+
32+
if (packageManifest.version !== version) {
33+
throw new Error(`package.json is ${packageManifest.version}, expected ${version}`);
34+
}
35+
if (pluginManifest.version !== version) {
36+
throw new Error(`manifest.json is ${pluginManifest.version}, expected ${version}`);
37+
}
38+
if (versions[version] !== pluginManifest.minAppVersion) {
39+
throw new Error(`versions.json does not map ${version} to ${pluginManifest.minAppVersion}`);
40+
}
41+
if (existsSync("updates.md")) {
42+
const content = readFileSync("updates.md", "utf8");
43+
const firstHeading = content.match(/^## .+$/m)?.[0];
44+
if (firstHeading !== `## ${version}`) {
45+
throw new Error(`updates.md starts with ${firstHeading ?? "no release heading"}, expected ## ${version}`);
46+
}
47+
}
48+
}
49+
50+
const [command, version] = process.argv.slice(2);
51+
if (!version) throw new Error("Usage: node release-process.mjs <input|prepare|validate> <version>");
52+
53+
if (command === "input") {
54+
assertReleaseVersion(version);
55+
} else if (command === "prepare") {
56+
assertReleaseVersion(version);
57+
prepareReleaseNotes(version);
58+
validateVersionFiles(version);
59+
} else if (command === "validate") {
60+
validateVersionFiles(version);
61+
} else {
62+
throw new Error(`Unknown release-process command: ${command}`);
63+
}

0 commit comments

Comments
 (0)