From 0a23e5b631a074c7d29862f058b9fcd53d27e8a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 15:18:40 +0000 Subject: [PATCH 1/3] fix(release): restore the release-asset URL form GreasyFork sync requires GreasyFork stopped picking up new versions after #40 switched every published script's @downloadURL/@updateURL (and, with them, the sync URL registered on GreasyFork) from https://github.com///releases/latest/download/.user.js to https://raw.githubusercontent.com///latest/packages//dist/script.user.js #40's reasoning covered only half of GreasyFork's contract. It is right that `file_from_root_for_url` reduces the raw-blob URL to the correct git path (`packages//dist/script.user.js`) by discarding `latest` as the ref. But matching a script is a round trip, not a one-way parse: on a "release published" webhook, GreasyFork feeds that path back through `urls_for_ref` to REGENERATE candidate URLs - once with the release's own tag_name, once with the default branch - and then selects scripts with `sync_identifier IN ()`, byte equality. `latest` is neither `release/` nor `main`, so the stored URL was never among the regenerated candidates, every delivery fell through to "No scripts found.", and nothing synced. `releases/latest/download/` is the one base form `urls_for_ref` special-cases to emit with no ref segment appended, so it is the only URL that round-trips to itself and stays stable across releases. Restored, keeping #48's derive-from-the-package-directory improvement: - build-userscript.mjs derives @downloadURL/@updateURL as releases/latest/download/.user.js (byte-identical to the pre-#40 values, verified against them for all three packages). - release.yaml re-adds the flat repo-root .user.js mirror in the tagged dist commit - `file_from_root_for_url` discards the packages//dist/ nesting, so that flat path is what GreasyFork reads with `git show :`. - release.yaml re-adds the release-asset upload - GreasyFork reads content from git, but userscript managers that installed straight from GitHub poll @updateURL over HTTP, which only resolves with the asset attached. - The floating `latest` git tag stays put so scripts installed via the raw-blob URL during the #40 window keep updating. - Documented the whole matching contract at the top of release.yaml, in build-userscript.mjs, and in the README, since the trap is in the step that is easy to miss. Verified by porting GreasyFork's own `file_from_root_for_url` / `urls_for_ref` / `inject_script_info` logic and replaying both URL forms: the raw-blob-at-latest URL fails the match, the release-asset URL matches. NOTE: the sync URL is an account-side setting on GreasyFork, so each script's "sync from URL" must be set back to https://github.com/nsheaps/greasemonkey-scripts/releases/latest/download/.user.js for this to take effect. --- .github/workflows/release.yaml | 143 ++++++++++++++++++++++++++------- README.md | 32 ++++++-- scripts/build-userscript.mjs | 16 +++- 3 files changed, 151 insertions(+), 40 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7ff0b4e..c24a14c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -18,24 +18,55 @@ name: Release # lint/format commit that would trigger another bump. # # A second commit, stacked on top of the bump commit but never merged into -# main, adds the built .user.js files to git at their normal -# packages//dist/ path (no flat root-level mirror needed - see below). -# Tagging that commit as the release tag, and moving the floating `latest` -# tag onto it, gives GreasyFork a git ref it can read the real nested path -# from, without ever putting build artifacts in main's history. +# main, adds the built .user.js files to git: each at its normal +# packages//dist/ path, AND mirrored flat at repo root as +# .user.js. Tagging that commit (as the release tag, and as the +# floating `latest` tag) gives GreasyFork a git ref it can read the flat +# path from, without ever putting build artifacts in main's history. # -# Each package's @downloadURL/@updateURL points at -# https://raw.githubusercontent.com///latest/packages//dist/script.user.js. -# On GreasyFork's "release published" webhook, it strips the domain+org+repo -# prefix from that URL, then discards exactly one more path segment as the -# ref (see lib/github.rb#file_from_root_for_url in GreasyFork's source) - -# here that's the `latest` segment - leaving `packages//dist/script.user.js` -# as the real git path, which it then reads with `git show :` -# (a server-side git operation, not an HTTP fetch of the stored URL - the -# `latest` segment is never itself dereferenced by that fetch, only by other -# tools like Tampermonkey polling @updateURL directly). Since the dist commit -# already places the file at that exact nested path, no flat mirror or -# release-asset upload is needed. +# --------------------------------------------------------------------------- +# GreasyFork's release-webhook sync contract - READ BEFORE CHANGING ANY URL +# --------------------------------------------------------------------------- +# Every published package's @downloadURL/@updateURL is +# https://github.com///releases/latest/download/.user.js, +# and that same URL is what each script's account-side "sync from URL" on +# GreasyFork must be set to. That exact shape is load-bearing. On a +# "release published" webhook, GreasyFork (lib/github.rb, +# app/controllers/concerns/webhooks.rb) does a ROUND TRIP: +# +# 1. It lists the sync URLs it already has for this repo, and reduces each +# to a repo-root-relative git path with `file_from_root_for_url` - which +# strips the repo/host prefix and then discards exactly one more path +# segment as the ref. For the URL above that yields `.user.js`. +# 2. It REGENERATES candidate URLs from that path with `urls_for_ref`, once +# using the release's own tag_name and once using the default branch, +# and then matches scripts with `sync_identifier IN ()` - +# byte equality, not a prefix match. +# 3. Only for matched scripts does it read the content, via +# `git show :` server-side (not an HTTP fetch of the +# stored URL). +# +# Step 2 is the trap. `.../releases/latest/download/` is the ONE base form +# `urls_for_ref` special-cases to emit with no ref segment appended, so it +# round-trips back to itself and matches. Every other supported form +# (github.com///raw/..., raw.githubusercontent.com///...) +# is regenerated with the release tag or the default branch substituted into +# the ref slot - so a sync URL naming any OTHER ref can never be reproduced +# and never matches. That is exactly how this broke before: pointing the URLs +# at raw.githubusercontent.com///latest/packages//dist/... got +# step 1 right but silently failed step 2, because `latest` is neither the +# release tag (release/) nor the default branch (main), so every +# webhook delivery came back "No scripts found." and no script ever synced. +# +# Step 3 is why the flat root-level mirror below exists: the path from step 1 +# is `.user.js`, so that is the path GreasyFork asks git for at the +# release tag. Step 1 discards the packages//dist/ nesting, so the +# nested copy alone is not readable through this URL form. +# +# The release-asset upload at the end of this job is a separate requirement: +# GreasyFork reads content from git, but Tampermonkey/Greasemonkey users who +# installed straight from GitHub poll @updateURL over plain HTTP, and that +# URL only resolves if the asset is actually attached to the latest release. on: push: @@ -223,6 +254,15 @@ jobs: exit 1 fi git add -f "$src" + + # Flat root-level mirror, named to match each package's + # @downloadURL/@updateURL asset name - see the contract comment at + # the top of this file for why GreasyFork's release-webhook sync + # reads this exact path instead of the nested + # packages//dist/ one. + root_copy="${pkg_name}.user.js" + cp "$src" "$root_copy" + git add -f "$root_copy" done git commit -m "chore(release): include built userscripts for GreasyFork sync [skip ci]" echo "dist-sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" @@ -243,21 +283,23 @@ jobs: # # JUDGEMENT CALL: one shared release per run, not one per package. # Every published script's @downloadURL points at the stable - # .../latest/packages//dist/script.user.js path. Per-package - # tags would make "latest" ambiguous - whichever package released - # most recently would win, and the other scripts' download URLs - # would resolve to a release commit that predates their own most - # recent build. Moving "latest" onto every dist commit (not just - # ones with bumps for that package - the dist commit above rebuilds - # every greasyforkPublish package) keeps every script's download URL - # resolvable. Per-package provenance is not lost: each package - # keeps its own version in its package.json and its own - # CHANGELOG.md. + # .../releases/latest/download/.user.js path. Per-package tags + # would make "latest" ambiguous - whichever package released most + # recently would win, and the other scripts' download URLs would + # resolve to a release that has no asset for them. That's also why + # the upload step below re-uploads every greasyforkPublish package's + # asset on every run, not just the ones bumped this run: "latest" is + # one shared pointer, so leaving an unchanged package out would make + # its download URL 404 the instant any other package's release moved + # past it. Per-package provenance is not lost: each package keeps + # its own version in its package.json and its own CHANGELOG.md. RELEASE_TAG="release/$(date -u +%Y%m%d-%H%M%S)" git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" - # Floating tag every package's @downloadURL/@updateURL resolves - # through (raw.githubusercontent.com///latest/...) so - # each script's URL never has to change again. + # Floating tag kept pointing at the newest dist commit. Nothing in + # this pipeline resolves through it any more, but it is what the + # raw.githubusercontent.com///latest/packages//dist/ + # URLs shipped between #40 and this change point at, so anyone who + # installed a script during that window keeps getting updates. git tag -f "latest" HEAD # release/last-run stays on the bump commit (not the dist commit) - # it's the base the NEXT run diffs source changes against. @@ -291,3 +333,44 @@ jobs: --title "$RELEASE_TAG" \ --notes-file /tmp/release-notes.md \ --latest + + - name: Upload userscripts as release assets + if: steps.bump.outputs.has-bumps == 'true' + env: + GH_TOKEN: ${{ steps.auth.outputs.token }} + RELEASE_TAG: ${{ steps.tag.outputs.release-tag }} + run: | + set -euo pipefail + UPLOAD_DIR="$(mktemp -d)" + + # Every greasyforkPublish package gets its asset re-uploaded on + # every release, not just the ones bumped this run. "latest" is one + # shared pointer across all published scripts' @downloadURL and + # GreasyFork sync identifiers - a script left out here would vanish + # from releases/latest/download/.user.js the moment any OTHER + # script's release moves "latest" past it, even though its own code + # never changed. `yarn run build` above already built every + # package, bumped or not. + for pkg_dir in packages/*/; do + pkg_name="$(basename "$pkg_dir")" + pjson="${pkg_dir}package.json" + [ -f "$pjson" ] || continue + opted_in="$(node -pe "require('./$pjson').greasyforkPublish === true" 2>/dev/null || echo false)" + [ "$opted_in" = "true" ] || continue + + # Rename to .user.js so several packages' assets + # coexist on one release and each matches the fixed + # .../releases/latest/download/.user.js URL baked into that + # package's own @downloadURL/@updateURL - which is also the URL + # GreasyFork's sync round-trip requires (see the contract comment + # at the top of this file). + src="packages/${pkg_name}/dist/script.user.js" + if [ ! -f "$src" ]; then + echo "::error::greasyforkPublish is set for $pkg_name but $src is missing after build" + exit 1 + fi + dest="${UPLOAD_DIR}/${pkg_name}.user.js" + cp "$src" "$dest" + echo "Uploading $dest to $RELEASE_TAG" + gh release upload "$RELEASE_TAG" "$dest" --repo "${{ github.repository }}" --clobber + done diff --git a/README.md b/README.md index a466e04..dbae24b 100644 --- a/README.md +++ b/README.md @@ -55,15 +55,31 @@ This repository uses a monorepo structure with the following setup: - A patch bump is applied to each package whose files changed since the last release. Bumping a version by hand in a PR (e.g. for a minor or major release) is respected and not bumped again on top. -- Each release run also commits every published package's built - `dist/script.user.js`, tags it, and moves a floating `latest` tag onto that - commit. Published scripts point their `@downloadURL`/`@updateURL` at - `https://raw.githubusercontent.com/nsheaps/greasemonkey-scripts/latest/packages//dist/script.user.js`, +- Each release run cuts one shared GitHub Release, attaches every published + package's built script to it as `.user.js`, and also commits + those built files at a tagged (but never merged) commit - both at + `packages//dist/script.user.js` and mirrored flat at repo root + as `.user.js`. +- Published scripts point their `@downloadURL`/`@updateURL` at + `https://github.com/nsheaps/greasemonkey-scripts/releases/latest/download/.user.js`, so a userscript manager installed directly from GitHub auto-updates from - there. [GreasyFork](https://greasyfork.org/en/scripts?by=1372068) forcibly - rewrites those same fields for any script actually listed on its site, so - the existing GreasyFork listings keep updating through GreasyFork's own - mechanism instead - this pipeline doesn't change that. + there. +- That same URL is what each script's **"sync from URL"** setting on + [GreasyFork](https://greasyfork.org/en/scripts?by=1372068) must be set to + (an account-side setting, not something this repo can change). GreasyFork's + release webhook only syncs a script when it can regenerate that script's + stored sync URL byte-for-byte from the release payload, and + `releases/latest/download/...` is the only supported URL form that contains + no branch/tag segment - so it is the only one that stays stable across + releases. A sync URL pointing at any other ref (a raw blob at `latest`, at a + version tag, ...) silently never matches and the script never updates. See + the contract comment at the top of `.github/workflows/release.yaml` before + changing any of these URLs. +- GreasyFork rewrites `@downloadURL`/`@updateURL` in the copy it serves, so + people who installed a script *from* GreasyFork update through GreasyFork + regardless. That only decides where an installed script polls - it is not + what gets a new version into GreasyFork in the first place, which is what + the sync URL above is for. - `yarn bump` runs the same bump logic locally; pass `--preview` to see what would happen without writing anything. diff --git a/scripts/build-userscript.mjs b/scripts/build-userscript.mjs index aff84db..46c1b88 100644 --- a/scripts/build-userscript.mjs +++ b/scripts/build-userscript.mjs @@ -28,6 +28,16 @@ import { basename, resolve } from "node:path"; const REPO_ORG = "nsheaps"; const REPO_NAME = "greasemonkey-scripts"; +// DO NOT change the SHAPE of this URL without re-reading the contract in +// .github/workflows/release.yaml. GreasyFork's release-webhook sync matches a +// script only when its account-side "sync from URL" value is byte-identical to +// a URL GreasyFork itself regenerates, and `releases/latest/download/` is +// the only supported form that carries no branch/tag segment - every other form +// is regenerated with the release tag or the default branch substituted in, so +// a URL naming any other ref (`latest`, a version tag, ...) can never match. +const releaseAssetUrl = (assetName) => + `https://github.com/${REPO_ORG}/${REPO_NAME}/releases/latest/download/${assetName}`; + const pkgDir = process.cwd(); const readJson = (relPath) => JSON.parse(readFileSync(resolve(pkgDir, relPath), "utf8")); @@ -56,8 +66,10 @@ for (const key of ["downloadURL", "updateURL"]) { } // The last segment of the package root is the directory name under packages/, -// which is the path GreasyFork and Tampermonkey fetch the built script from. -const scriptUrl = `https://raw.githubusercontent.com/${REPO_ORG}/${REPO_NAME}/latest/packages/${basename(pkgDir)}/dist/script.user.js`; +// which the release workflow also uses as the release asset's name (and as the +// name of the flat repo-root copy GreasyFork reads out of the release tag), so +// several packages' assets can coexist on one shared release. +const scriptUrl = releaseAssetUrl(`${basename(pkgDir)}.user.js`); // Always emit whatever package.json currently holds. Between releases that is // the last released version, which is honest for a local build, and on a From f992ae1b3cd4b4fc46bb24ba04568968a9a46e5d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 15:23:29 +0000 Subject: [PATCH 2/3] fix(release): treat a build-script change as changing every published package The URL fix in the previous commit touches only scripts/build-userscript.mjs, and change detection scopes its diff to packages//. So the release job would have computed has_bumps=false on merge, skipped every one of its conditional steps, and shipped nothing - the corrected @downloadURL/@updateURL would never have been built, uploaded, or synced. Verified before the change: $ ./scripts/auto-bump-packages.sh --change-base=main --version-base=main --preview false | _no package changes detected_ | | | | scripts/build-userscript.mjs renders the `// ==UserScript==` metablock into every published artifact, so a change to it rewrites every artifact's bytes. Declare it as a shared build input: when it differs from the change base, every opted-in package counts as changed. Deliberately narrow - exactly the one file whose content lands inside every built script, not "any file outside packages/". Adding a shared input here is the mechanism for anything else that gets baked into every artifact later. After: $ ./scripts/auto-bump-packages.sh --change-base=main --version-base=main --preview true | github-actions-grafana-jump | 0.2.13 | 0.2.14 | will-bump | | github-to-graphite-button | 0.3.8 | 0.3.9 | will-bump | | graphite-to-github-button | 0.3.11 | 0.3.12 | will-bump | Per-package detection is unchanged when no shared input moved, checked against real history in a worktree at 215ef50 (a commit touching one package only): $ ./scripts/auto-bump-packages.sh --change-base=215ef50^ --version-base=215ef50^ --preview true | github-actions-grafana-jump | 0.2.12 | 0.2.13 | will-bump | and a no-op diff still reports nothing: $ ./scripts/auto-bump-packages.sh --change-base=main --version-base=main --preview # on main false | _no package changes detected_ | | | | --- scripts/auto-bump-packages.sh | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/scripts/auto-bump-packages.sh b/scripts/auto-bump-packages.sh index 7db76b1..4b5fb3d 100755 --- a/scripts/auto-bump-packages.sh +++ b/scripts/auto-bump-packages.sh @@ -61,6 +61,26 @@ echo "Version comparison base: $VERSION_BASE" >&2 RELEASE_IT="$ROOT_DIR/node_modules/.bin/release-it" +# Shared build inputs: files outside packages/ whose content is baked into +# EVERY published script.user.js. scripts/build-userscript.mjs renders the +# `// ==UserScript==` metablock (@version, @downloadURL, @updateURL, ...) into +# each package's artifact, so changing it changes every published artifact's +# bytes even when no package's own files moved. +# +# Without this, per-package change detection below - which only looks inside +# packages// - would report "no package changes detected", has_bumps would +# be false, and the release job would skip every step. A fix to the header +# would merge and then silently never reach anyone, because nothing would +# rebuild or republish. That is a real failure mode, not a hypothetical: the +# @downloadURL/@updateURL fix that restored GreasyFork sync changed only this +# script and would have shipped nothing on its own. +SHARED_BUILD_INPUTS=("scripts/build-userscript.mjs") +SHARED_CHANGED=false +if git diff --name-only "$CHANGE_BASE..HEAD" -- "${SHARED_BUILD_INPUTS[@]}" 2>/dev/null | grep -q .; then + SHARED_CHANGED=true + echo "shared build input changed: every published package counts as changed" >&2 +fi + # Compute the next patch version for a semver string (major.minor.patch). next_patch() { local v="$1" a b c @@ -129,12 +149,15 @@ for pkg_dir in packages/*/; do # further to detect or report. [ "$opted_in" = "true" ] || continue - # A package counts as "changed" if any of its files, other than - # package.json and CHANGELOG.md, differ from the change base. We skip - # those two files so that a previous version-bump commit doesn't trigger - # another bump on its own. We only get here after already checking above - # for a hand-bumped version, so this check never hides one of those. - if ! git diff --name-only "$CHANGE_BASE..HEAD" -- "$pkg_dir" 2>/dev/null \ + # A package counts as "changed" if a shared build input changed (see + # SHARED_BUILD_INPUTS above - that rewrites every published artifact), or if + # any of its own files, other than package.json and CHANGELOG.md, differ from + # the change base. We skip those two files so that a previous version-bump + # commit doesn't trigger another bump on its own. We only get here after + # already checking above for a hand-bumped version, so this check never hides + # one of those. + if [ "$SHARED_CHANGED" = false ] \ + && ! git diff --name-only "$CHANGE_BASE..HEAD" -- "$pkg_dir" 2>/dev/null \ | grep -vE '(^|/)(package\.json|CHANGELOG\.md)$' | grep -q .; then continue fi From 9d7aef69033631b5a08127bce75a8ab39d57d164 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 15:36:38 +0000 Subject: [PATCH 3/3] refactor(release): address review - guard shared inputs, dedupe opt-in checks All four P2 nits from Henry's review on #59. 1. SHARED_BUILD_INPUTS entries are git pathspecs, and a pathspec matching nothing exits 0 with no output - verified: $ git diff --name-only HEAD~1..HEAD -- scripts/does-not-exist.mjs exit=0 So renaming a listed file would silently flip SHARED_CHANGED back to false and re-arm the exact ship-nothing failure the mechanism exists to prevent. Each entry is now verified to resolve at HEAD first, failing loudly instead. 2. Dropped -f from the root-copy `git add`. Confirmed only the nested path is ignored, so -f was misleading there: $ git check-ignore -v github-to-graphite-button.user.js # no match $ git check-ignore -v packages/.../dist/script.user.js .gitignore:147:dist/ packages/.../dist/script.user.js Kept -f on the nested add, with a comment on each saying which is which. 3. Removed the dead `dist-sha` step output (and the now-unused `id:` on that step) - nothing read `steps.dist-commit.outputs.dist-sha`; `Tag release` reaches the dist commit via bare HEAD. 4. Extracted the greasyforkPublish opt-in check into scripts/publishable-packages.sh. It was spelled out in three places - the two release.yaml steps plus auto-bump-packages.sh - so renaming the flag or adding a condition meant editing three places, with a half-applied change shipping silently. Now one place answers it. Output verified identical to the previous inline check. Both callers capture the helper's output into a variable rather than process-substituting it, so a failure in the helper aborts the step under `set -e` instead of feeding the loop an empty list - the same silent-degrade class as nit 1. Added an explicit empty-list guard for the same reason: a release run reaching those steps with nothing publishable is an error, not a no-op. Dropped the duplicated missing-artifact guard in the upload step per the review's alternative, since the earlier dist-commit step already fails on that condition and `cp` under `set -e` would too. Validation: # detection unchanged for this PR $ ./scripts/auto-bump-packages.sh --change-base=main --version-base=main --preview true | github-actions-grafana-jump | 0.2.13 | 0.2.14 | will-bump | | github-to-graphite-button | 0.3.8 | 0.3.9 | will-bump | | graphite-to-github-button | 0.3.11 | 0.3.12 | will-bump | # new guard fires on a stale entry (previously: silent false) $ sed -i 's|build-userscript.mjs|renamed-away.mjs|' scripts/auto-bump-packages.sh $ ./scripts/auto-bump-packages.sh --change-base=main --version-base=main --preview ::error::SHARED_BUILD_INPUTS entry 'scripts/renamed-away.mjs' does not exist at HEAD exit=1 # helper output identical to the old inline check $ diff /tmp/old-optin.txt /tmp/new-optin.txt # no differences # no over-triggering: package-only change at 215ef50 still bumps one package $ (cd && ./scripts/auto-bump-packages.sh \ --change-base=215ef50^ --version-base=215ef50^ --preview) | github-actions-grafana-jump | 0.2.12 | 0.2.13 | will-bump | # no-op diff on main still reports nothing $ (cd && ./scripts/auto-bump-packages.sh \ --change-base=main --version-base=main --preview) false | _no package changes detected_ | | | | # both rewritten release loops dry-run correctly (git/gh echoed) $ git add -f packages//dist/script.user.js git add .user.js x3 gh release upload /.user.js --clobber x3 $ bash -n scripts/auto-bump-packages.sh scripts/publishable-packages.sh $ python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yaml'))" --- .github/workflows/release.yaml | 47 ++++++++++++++++----------------- scripts/auto-bump-packages.sh | 24 ++++++++++++++++- scripts/publishable-packages.sh | 26 ++++++++++++++++++ 3 files changed, 72 insertions(+), 25 deletions(-) create mode 100755 scripts/publishable-packages.sh diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c24a14c..65ba82b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -230,7 +230,6 @@ jobs: run: yarn run build - name: Commit built userscripts for GreasyFork sync - id: dist-commit if: steps.bump.outputs.has-bumps == 'true' env: GIT_AUTHOR_NAME: ${{ steps.auth.outputs.user-name }} @@ -239,33 +238,37 @@ jobs: GIT_COMMITTER_EMAIL: ${{ steps.auth.outputs.user-email }} run: | set -euo pipefail - # dist/ is gitignored for normal development. Force-add just the - # built files GreasyFork needs to read via `git show`. - for pkg_dir in packages/*/; do - pkg_name="$(basename "$pkg_dir")" - pjson="${pkg_dir}package.json" - [ -f "$pjson" ] || continue - opted_in="$(node -pe "require('./$pjson').greasyforkPublish === true" 2>/dev/null || echo false)" - [ "$opted_in" = "true" ] || continue + # Which packages are published is answered in exactly one place - + # see scripts/publishable-packages.sh. Captured into a variable + # rather than piped/process-substituted so that a failure in the + # helper aborts this step under `set -e` instead of silently + # feeding the loop an empty list. + PUBLISHABLE="$(scripts/publishable-packages.sh)" + if [ -z "$PUBLISHABLE" ]; then + echo "::error::no packages have greasyforkPublish set, but a release was cut - nothing would be published" + exit 1 + fi + while read -r pkg_name; do src="packages/${pkg_name}/dist/script.user.js" if [ ! -f "$src" ]; then echo "::error::greasyforkPublish is set for $pkg_name but $src is missing after build" exit 1 fi + # dist/ is gitignored for normal development, so this one needs -f. git add -f "$src" # Flat root-level mirror, named to match each package's # @downloadURL/@updateURL asset name - see the contract comment at # the top of this file for why GreasyFork's release-webhook sync # reads this exact path instead of the nested - # packages//dist/ one. + # packages//dist/ one. No -f here: nothing in .gitignore + # covers .user.js at repo root. root_copy="${pkg_name}.user.js" cp "$src" "$root_copy" - git add -f "$root_copy" - done + git add "$root_copy" + done <<< "$PUBLISHABLE" git commit -m "chore(release): include built userscripts for GreasyFork sync [skip ci]" - echo "dist-sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Tag release id: tag @@ -351,13 +354,13 @@ jobs: # script's release moves "latest" past it, even though its own code # never changed. `yarn run build` above already built every # package, bumped or not. - for pkg_dir in packages/*/; do - pkg_name="$(basename "$pkg_dir")" - pjson="${pkg_dir}package.json" - [ -f "$pjson" ] || continue - opted_in="$(node -pe "require('./$pjson').greasyforkPublish === true" 2>/dev/null || echo false)" - [ "$opted_in" = "true" ] || continue + PUBLISHABLE="$(scripts/publishable-packages.sh)" + if [ -z "$PUBLISHABLE" ]; then + echo "::error::no packages have greasyforkPublish set, but a release was cut - nothing would be uploaded" + exit 1 + fi + while read -r pkg_name; do # Rename to .user.js so several packages' assets # coexist on one release and each matches the fixed # .../releases/latest/download/.user.js URL baked into that @@ -365,12 +368,8 @@ jobs: # GreasyFork's sync round-trip requires (see the contract comment # at the top of this file). src="packages/${pkg_name}/dist/script.user.js" - if [ ! -f "$src" ]; then - echo "::error::greasyforkPublish is set for $pkg_name but $src is missing after build" - exit 1 - fi dest="${UPLOAD_DIR}/${pkg_name}.user.js" cp "$src" "$dest" echo "Uploading $dest to $RELEASE_TAG" gh release upload "$RELEASE_TAG" "$dest" --repo "${{ github.repository }}" --clobber - done + done <<< "$PUBLISHABLE" diff --git a/scripts/auto-bump-packages.sh b/scripts/auto-bump-packages.sh index 4b5fb3d..a732480 100755 --- a/scripts/auto-bump-packages.sh +++ b/scripts/auto-bump-packages.sh @@ -75,12 +75,30 @@ RELEASE_IT="$ROOT_DIR/node_modules/.bin/release-it" # @downloadURL/@updateURL fix that restored GreasyFork sync changed only this # script and would have shipped nothing on its own. SHARED_BUILD_INPUTS=("scripts/build-userscript.mjs") + +# These are used as git PATHSPECS below, and a pathspec that matches nothing is +# not an error - `git diff -- some/renamed/path` exits 0 with no output. So a +# renamed or moved entry here would degrade silently to "no shared input +# changed", which is precisely the ship-nothing failure this whole mechanism +# exists to prevent, just re-armed under a different trigger. Verify each entry +# resolves at HEAD first, so a stale entry fails loudly instead. +for input in "${SHARED_BUILD_INPUTS[@]}"; do + if ! git cat-file -e "HEAD:$input" 2>/dev/null; then + echo "::error::SHARED_BUILD_INPUTS entry '$input' does not exist at HEAD - update scripts/auto-bump-packages.sh" >&2 + exit 1 + fi +done + SHARED_CHANGED=false if git diff --name-only "$CHANGE_BASE..HEAD" -- "${SHARED_BUILD_INPUTS[@]}" 2>/dev/null | grep -q .; then SHARED_CHANGED=true echo "shared build input changed: every published package counts as changed" >&2 fi +# Single source of truth for "is this package published?" - see +# scripts/publishable-packages.sh for why this is not spelled out inline. +PUBLISHABLE="$(scripts/publishable-packages.sh)" + # Compute the next patch version for a semver string (major.minor.patch). next_patch() { local v="$1" a b c @@ -102,7 +120,11 @@ for pkg_dir in packages/*/; do # Whether this package is in the release pipeline. Computed up front but # not gated on yet - the manual-bump check just below applies to every # package so a hand-bumped internal package still shows up in the preview. - opted_in="$(node -pe "require('./$pjson').greasyforkPublish === true" 2>/dev/null || echo false)" + if printf '%s\n' "$PUBLISHABLE" | grep -qxF "$name"; then + opted_in=true + else + opted_in=false + fi base_version="$(git show "$VERSION_BASE:$pjson" 2>/dev/null \ | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo '0.0.0')" diff --git a/scripts/publishable-packages.sh b/scripts/publishable-packages.sh new file mode 100755 index 0000000..043a6e6 --- /dev/null +++ b/scripts/publishable-packages.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Prints the directory name of every package in the release pipeline, one per +# line, in packages/ order. +# +# A package opts in with `"greasyforkPublish": true` in its own package.json. +# That flag decides three separate things - whether a package gets auto-bumped +# (scripts/auto-bump-packages.sh), whether its built script is committed and +# mirrored at repo root for GreasyFork to read, and whether it is uploaded as a +# release asset (both in .github/workflows/release.yaml). Those three used to +# each spell the check out themselves, so renaming the flag or adding a second +# condition meant editing three places and silently shipping a half-applied +# change if you missed one. This script is the single place that answers "is +# this package published?". +# +# Usage: scripts/publishable-packages.sh (run from anywhere in the repo) +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +for pkg_dir in packages/*/; do + pjson="${pkg_dir}package.json" + [ -f "$pjson" ] || continue + opted_in="$(node -pe "require('./$pjson').greasyforkPublish === true" 2>/dev/null || echo false)" + [ "$opted_in" = "true" ] || continue + basename "$pkg_dir" +done