diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7ff0b4e..65ba82b 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: @@ -199,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 }} @@ -208,24 +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" - done + + # 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. No -f here: nothing in .gitignore + # covers .user.js at repo root. + root_copy="${pkg_name}.user.js" + cp "$src" "$root_copy" + 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 @@ -243,21 +286,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 +336,40 @@ 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. + 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 + # 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" + 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 <<< "$PUBLISHABLE" 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/auto-bump-packages.sh b/scripts/auto-bump-packages.sh index 7db76b1..a732480 100755 --- a/scripts/auto-bump-packages.sh +++ b/scripts/auto-bump-packages.sh @@ -61,6 +61,44 @@ 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") + +# 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 @@ -82,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')" @@ -129,12 +171,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 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 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