Skip to content
Draft
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
164 changes: 123 additions & 41 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pkg>/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/<pkg>/dist/ path, AND mirrored flat at repo root as
# <pkg>.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/<org>/<repo>/latest/packages/<pkg>/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/<pkg>/dist/script.user.js`
# as the real git path, which it then reads with `git show <release-tag>:<path>`
# (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/<org>/<repo>/releases/latest/download/<pkg>.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 `<pkg>.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 (<those URLs>)` -
# byte equality, not a prefix match.
# 3. Only for matched scripts does it read the content, via
# `git show <release-tag>:<path>` 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/<org>/<repo>/raw/..., raw.githubusercontent.com/<org>/<repo>/...)
# 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/<org>/<repo>/latest/packages/<pkg>/dist/... got
# step 1 right but silently failed step 2, because `latest` is neither the
# release tag (release/<timestamp>) 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 `<pkg>.user.js`, so that is the path GreasyFork asks git for at the
# release tag. Step 1 discards the packages/<pkg>/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:
Expand Down Expand Up @@ -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 }}
Expand All @@ -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/<pkg>/dist/ one. No -f here: nothing in .gitignore
# covers <name>.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
Expand All @@ -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/<pkg>/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/<pkg>.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/<org>/<repo>/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/<org>/<repo>/latest/packages/<pkg>/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.
Expand Down Expand Up @@ -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/<name>.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 <package-dir-name>.user.js so several packages' assets
# coexist on one release and each matches the fixed
# .../releases/latest/download/<name>.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"
32 changes: 24 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<package-name>/dist/script.user.js`,
- Each release run cuts one shared GitHub Release, attaches every published
package's built script to it as `<package-name>.user.js`, and also commits
those built files at a tagged (but never merged) commit - both at
`packages/<package-name>/dist/script.user.js` and mirrored flat at repo root
as `<package-name>.user.js`.
- Published scripts point their `@downloadURL`/`@updateURL` at
`https://github.com/nsheaps/greasemonkey-scripts/releases/latest/download/<package-name>.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.

Expand Down
59 changes: 52 additions & 7 deletions scripts/auto-bump-packages.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pkg>/ - 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
Comment thread
nsheaps marked this conversation as resolved.
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
Expand All @@ -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')"
Expand Down Expand Up @@ -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
Expand Down
Loading