From a107499409f44422c6f92ed7d7f5a2ce0d046feb Mon Sep 17 00:00:00 2001 From: Vie <125633564+Vieeeeeee@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:50:03 +0800 Subject: [PATCH 1/2] ci: cut the GitHub Release after npm accepts the package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish workflow only ran npm publish. Tags for 0.3.0, 0.4.0 and 0.5.0 existed and all three were on npm, but the repository page still advertised v0.2.0 as Latest — three days stale, and reading like an abandoned project to anyone landing on it. The in-app update check points at that same page. The step runs after npm publish, so a Release never announces a version nobody can install yet. Notes come from the CHANGELOG section for the tag's version, which is written before the tag exists; an empty section fails the step rather than publishing a Release with nothing in it. --verify-tag refuses a tag that is not on the remote. contents: write is the one permission this adds. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99e486d..f745223 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,10 @@ on: workflow_dispatch: permissions: - contents: read + # contents: write is what lets the last step cut the GitHub Release. Publishing + # to npm alone left the repository page advertising an old version as "Latest", + # which reads as an abandoned project to anyone landing on it. + contents: write id-token: write concurrency: @@ -43,3 +46,29 @@ jobs: - name: Publish public package run: npm publish --access public --provenance + + # Runs only after npm accepted the package, so a GitHub Release never + # announces a version nobody can install. The notes are the CHANGELOG + # section for this version, which is written before the tag exists. + - name: Cut the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + shell: bash + run: | + VERSION="${TAG#v}" + # From this version's heading to the next one, minus that next heading. + awk -v v="$VERSION" ' + $0 ~ "^## \\[" v "\\]" { in_section = 1; next } + in_section && /^## \[/ { exit } + in_section { print } + ' CHANGELOG.md > release-notes.md + if [[ ! -s release-notes.md ]]; then + echo "No CHANGELOG section for $VERSION; refusing to publish empty notes." >&2 + exit 1 + fi + gh release create "$TAG" \ + --title "SkillHub $TAG" \ + --notes-file release-notes.md \ + --verify-tag \ + --latest From f5a02fd49c2f4efe3d092aa986864bc80e8082f8 Mon Sep 17 00:00:00 2001 From: Vie <125633564+Vieeeeeee@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:53:46 +0800 Subject: [PATCH 2/2] fix: retry the registry rename Windows refuses under contention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on windows-latest failed 2 runs out of 12 with EPERM: operation not permitted, rename '...tmp' -> 'registry.json' Every command rebuilds registry.json after releasing the override lock, so two concurrent `describe` calls race on that one rename. POSIX swaps the inode and always lands; Windows refuses to rename onto a file another process has open. The command then reported failure for a write that had already succeeded. Short bounded retries, because nothing holds this file for long. A lock is the heavier answer for a file that is a rebuildable cache — and overrides.json, the one holding data that cannot be rebuilt, already has one. The existing "parallel metadata writes keep every edit" test is the regression test; it is the one that was failing. Co-Authored-By: Claude Opus 5 --- src/core/registry.mjs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/core/registry.mjs b/src/core/registry.mjs index e64abe0..fbabffe 100644 --- a/src/core/registry.mjs +++ b/src/core/registry.mjs @@ -113,6 +113,28 @@ export function withOverridesLock(overridesFile, fn) { } } +// POSIX swaps the inode and the rename always lands. Windows refuses to rename +// onto a file another process currently has open, which is exactly what two +// concurrent commands do to registry.json — every command rebuilds it after the +// override lock has already been released. The write then failed with EPERM and +// the command reported failure for work that had actually succeeded. +// +// A few short retries clear it, because nothing holds this file for long. A lock +// would be the heavier answer for a file that is a rebuildable cache. +function renameWithRetry(tempFile, file) { + for (let attempt = 0; ; attempt += 1) { + try { + renameSync(tempFile, file); + return; + } catch (error) { + const contended = + error?.code === "EPERM" || error?.code === "EACCES" || error?.code === "EBUSY"; + if (!contended || attempt >= 10) throw error; + sleepSync(20); + } + } +} + function writeJsonAtomic(file, value) { const dir = dirname(file); const tempFile = join(dir, `.skillhub-write-${process.pid}-${randomUUID()}.tmp`); @@ -121,7 +143,7 @@ function writeJsonAtomic(file, value) { } try { writeFileSync(tempFile, JSON.stringify(value, null, 2), { encoding: "utf-8", mode: 0o600 }); - renameSync(tempFile, file); + renameWithRetry(tempFile, file); } catch (error) { try { unlinkSync(tempFile);