Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
24 changes: 23 additions & 1 deletion src/core/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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);
Expand Down