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 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);