Pinning plugin and theme versions for older Discourse installs

Hey everyone :wave: , I just merged a new feature that will help plugins and themes pin certain versions when installed on older Discourse instances.

You can now include a .discourse-compatibility file at the root of a plugin or theme repository, that designates what to checkout when installing on older Discourse versions.


Rationale

It sucks to remember which plugins and themes are compatible with which Discourse versions. As an admin, it should be possible to easily scan those changes and find a version that is right for your Discourse install without reading through plugin commit history. As a plugin or theme author, it should be possible to manage install versions while making backwards-incompatible changes so you don’t break older installs.

Discourse software updates roll out pretty quickly, which while awesome, makes for maintaining Discourse instances with a lot of plugins sometimes very difficult, especially if you are following other release cadences/versions such as the current stable. My plan here is to allow an ecosystem that eases the update process for those following either stable or some other release cadence, and give those site admins a method to quickly and automatically grab whatever plugin version was compatible for the Discourse version they are targeting.

Implementation

First thing to note: We are depending on Discourse core’s tags here, as Discourse’s betas ship frequently enough that we can pin plugin versions against them. Pinning against git hashes is a nightmare for a lot of reasons so we can git describe to get the closest beta/stable tag.

In a plugin or a theme, we now support a version compatibility file named .discourse-compatibility at the root. This file is a descending (newer Discourse versions first) ordered list that specifies a compatibility map.

Example

2.5.0.beta2: git-hash-1234e5f5d
2.4.4.beta6: 4444ffff33dd
2.4.2.beta1: named-git-tag-or-branch

For each plugin/theme an upgrade or rebuild will continue to checkout a later named commit/branch/tag until it finds one that is equal to, or later than the current Discourse version.
eg, for the above version file, if the current Discourse version was 2.4.6.beta12 it would scan the file and choose the entry for 2.5.0.beta2.

If the current Discourse version was 2.4.4.beta6 it would choose the matching entry for 2.4.4.beta6.

If no later version exists it stays on the current checked out version.
eg, for 2.5.0.beta3 no pinning would occur.

if no earlier version exists, it checks out the earliest one listed in the version file.
eg, for 2.2.1.beta22 it’d check out the earliest possible given “version”, the entry for 2.4.2.beta1.


The aim here is to ease the pain of maintaining alternate deployments that are not strictly on tests-passed in the future, and give flexibility to admins for when and where to upgrade. We’re doing this by allowing plugin and theme authors a way to develop backwards-incompatible changes without affecting installs on older versions of Discourse.

43 Likes

This is a great feature, thank you :slight_smile:

I like the directionality of this, i.e. it makes it possible to manage this issue from within the plugin itself, not requiring the site admin to do anything per se.

I have a few initial questions:

  • Will the existing required_version plugin metadata check in plugin activation remain? And how do you see that interelating with this (if at all)?

  • I see this is added in the form of a rake task atm. How does it relate to, what is the intended use, with discourse_docker (i.e. the launcher) and docker_manager? I see you’ve made additions to both repos, but could you explain how it’s intended to work in both environments?

12 Likes

Yeah, that’s the idea - make it so plugin authors have the capability to add backwards compatibility so that admins don’t need to worry!

There are currently no plans to change or remove the required_version plugin metadata. They’re related, but are still separate in my mind - the required_version min/max fullstop disallows the plugin from being installed by throwing an error, and is loaded after this compatibility pull. If you want to prevent ancient Discourse instances from using your plugin, I’d say it’s still a good idea to include required_version for the first minimum version - no amount of compatibility hunting is going to fix that :wink:

There shouldn’t be any config changes needed in normal use, it’ll pick up changes automatically. The rake task is called in discourse_docker after the plugins are cloned, so in the launcher order:

  • Plugins cloned
  • Compatibility checked, and checked out (if applicable)
  • migrate

In docker_manager, older Discourse versions will be able to update plugins to a compatible version. The UI here stays the same, but an “up to date” plugin is now according to the compatibility file.

TLDR, no changes are needed for either use-case to start taking advantage of this, if that’s what you’re wondering.

Under the hood, it uses git show HEAD@{upstream}:.discourse-compatibility to read the latest file, and git reset --hard #{checkout_version} to checkout the correct version. That way we are able to use the latest compatibility, and older Discourse versions will not be stuck on an old (possible invalid) compat file.

10 Likes

Cool, thanks for explaining that.

So I poured myself a :wine_glass: and gave this a shot with the Custom Wizard Plugin.

I checked each tag out one by one in reverse order starting with v2.6.0.beta1. I found these git commands helpful:

git tag --list \\ e.g. git tag --list 'v2.5.0*'
git checkout tags/tag \\ e.g. git checkout tags/v2.5.0.beta7

It didn’t take too long to find a tag that wasn’t working with the current version of the plugin: v2.5.0.beta7 doesn’t include discourse/app/components/d-textarea which the custom wizard tries to import.

So, then I found the commit in the plugin that added that import, took the sha1 of the previous commit, checked that out and tested (worked fine), and added this to .discourse-compatibility:

v2.5.0.beta7: 802d74bab2ebe19a106f75275342dc2e9cc6066a

I then pushed that to a branch with the latest plugin code (a branch for testing, not necessary normally), and rebuilt a dockerized test server with that plugin branch and the version set at v2.5.0.beta7.

That didn’t work, then it hit me that, of course, the rake task plugin:pull_compatible_all doesn’t exist in v2.5.0.beta7, so this isn’t going to work retrospectively (I blame the :wine_glass:). Sure enough in the launcher logs I see

Don't know how to build task 'plugin:pull_compatible_all' (See the list of available tasks with `rake --tasks`)

Is that the gist of how you imagine this being used though?

On the required_version front, I encountered that here as the test server had the discourse-legal-tools plugin installed, which has a required_version of v2.5.0, so it initially failed on v2.5.0.beta7. I think I’ll transfer that plugin over to this new system. I can still see required_version being useful to set an absolute baseline as you say.

10 Likes

Yeah that’s correct - because this isn’t baked into discourse core until now, it won’t work on anything older than 2.6.0.beta1 (currently). I still have to port this to stable + latest beta, so we’ll be able to use it against 2.5.0, but we’re not planning on porting it any earlier.

10 Likes

Just a follow up here that I’ve now added a compatbility file to Custom Wizard master and will be using it to pin the plugin, including v2.6.0.beta1 (current beta) and v2.5.0 (current stable) when this is ported. See further:

5 Likes

One note and a question here.

Firstly, just a note that the syntax for discourse version tags in the .discourse-compatibility file is 2.5.0 (like in @featheredtoast’s example), not v2.5.0. If you have a v you’ll get this error:

Malformed version number string v2.6.0.beta1

Secondly, @featheredtoast pointed out that the pinning system is now backported to stable. I missed this as I was looking at the v2.5.0 tag.

That raises a slight question for me. Discourse’s branches do not directly equate to discourse’s release tags. Most sites running older versions of discourse are probably on a branch, i.e. stable as opposed to a release, i.e. v2.5.0.

If a breaking change (for a plugin) is also added to an “older” branch, i.e. stable, what does this mean for the pins? I probably just don’t fully understand how the versioning system works.

6 Likes

Versions correspond to the discourse version defined in the app, not the git tag.

So “2.5.0” in this context will mean any version declared as 2.5.0, up to the new version bump of 2.5.1 (or 2.6.0.beta1).

If a breaking change (for a plugin) is also added to the stable branch, that would indeed also break the plugin. The entire point of versioning is so we can be relatively sure we aren’t introducing breaking changes to that version, so that’s something to scream about separately. Our intent for stable is to backport only security and critical fixes (and minor features, when appropriate).

The intent of this is so we have the ability to calculate older working versions of plugins against older working versions of Discourse. This is by no means a silver bullet, but it does give us a platform in which to do it, if we are actually careful about choosing what to backport.

7 Likes

This mostly works, unless you’re cloning with a --depth=1.

I’ll implement a hook to call a git fetch --depth 1 {upstream} commit soon if the target cannot be found initially, otherwise we’d be stuck doing a partial/full clone which is not ideal. We should be able to fetch what we need.

Edit: Updated here:

https://github.com/discourse/discourse/commit/787ad7d84ddb74a91e851c04ad13f7dab29ee96a

I’ve backported this one to Beta and Stable as well - so we should be able to pin even with shallow clones now.

6 Likes

Apologies in advance as I often turn to this somewhat late in the night, so I’m not sure if I’m 100% correct here, and this may well be something you’re aware of already. I’m memorialising it here partly for my own sanity as it’s tripped me up a couple of times now.

I think this mechanism is effectively unworkable for the beta branch if the plugin is used on an instance you don’t control (i.e. it’s open source) that’s being updated in the common fashion. The common fashion of updating is for the site admin to do so when prompted in the admin UI.

Given this

And this

And that tests-passed and beta have the same Discourse version but not the same code, e.g. both are currently 2.6.0.beta2:

This follows:

  1. To support the beta branch you need to pin a commit to the latest beta release, as that is the one sites on the beta branch will be using.

  2. However, if the latest beta release is in the compatability file, instances running tests-passed will also use the pinned commit.

This means you can’t support the standard usage of both tests-passed and beta at the same time in an open souce plugin. Given that the majority of people who install plugins are on tests-passed, you effectively can’t support beta via this method.

Note that it does work in practice for the stable branch as stable has a different Discourse version from beta and tests-passed.

4 Likes

Oh yeah, this is something I am aware of, it doesn’t truly resolve until the next beta cut. We should definitely also find a way to distinguish between beta and tests passed versions for this.

When I was setting the feature up, I had stable and weird forks in mind, and didn’t see until later that latest and stable were sharing versions.

5 Likes

Thanks for confirming.

It seems the effective upshot is that the process should not run if the instance is running tests-passed. You can’t include the tests-passed version in the file for the reasons described above, so precluding the process on tests-passed would not change its current behaviour.

One way to implement this would be

def self.find_compatible_resource(version_list, version = ::Discourse::VERSION::STRING)
 
   return if Discourse.git_branch === 'tests-passed'

   ...
end

This would make it possible to use the latest beta version in the file for the purposes of pinning plugins for sites running beta. And you could continue supporting tests-passed in the latest commit of the plugin, i.e. without using this file. If you’re on board with that I can make a PR.

Alternatively, I feel like the underlying issue here is this

I’m sure this is something you guys have discussed before, but would it be possible to do something like

  • tests-passed: 2.6.0.tests-passed, i.e. set the PRE as tests-passed on the tests-passed branch.

  • beta: 2.6.0.beta2, i.e. as it is currently

@jomaxro Could you help me understand this one?

7 Likes

yeah, I am in favor of adding an additional marking for pre-beta release vs an actual beta release, provided it’s easy enough to do. That would help resolve the underlying issue here. I’ve seen other software projects bump the version to the next version before its release (eg after releasing beta1, bump the “version” to beta2).

Traditionally though Discourse has not done that so it depends on what method is easiest for the project to adopt.

6 Likes

I ran into another issue.

This change introduces $danger-low-mid in 2.6.0beta2.

This broke the discourse-styleguide plugin so that was updated and a .discourse-compatibility file was introduced to keep the plugin at the previous commit for Discourse 2.5.0.

This breaks on Discourse 2.5.1. Since this change will never be backported to stable, the discourse-compatibility file would need to be updated on every new 2.5.x stable version.

Every discourse-compatibility file of every plugin would need to be updated on every new 2.5.x stable version.

Alternatively, the compatibility file could refer to version 2.5.999, effectively keeping the plugin at commit 1f86468b2c81b40e97f1bcd16ea4bb780634e2c7 for the entire 2.5.x lifetime. But this seems very hacky to me.

6 Likes

It sounds like it would be safe to pin to 2.6.0beta1, which would also be more correct as the issue was introduced in beta2, does that make sense? That would also then cover all 2.5 versions.

Your “hacky” solution of 2.5.999 sounds like it would be a neat workaround (although messy as you mentioned). I tried to keep pinning here as simple as I could, but you’re right in that we are still lacking a true way of stating 2.5.x as a valid pinned version.

Another workaround for that particular case for pinning last stable, but keeping next version’s betas unpinned that might be a bit more subtle but feels much less hacky to me for pinning the entire 2.5.x branch is make the pin at 2.6.0.beta0, or 2.6.0.alpha1 which semantically is pin everything before and up-to the version between 2.5.x and 2.6.0.beta1. This means:

Everything 2.5.x is covered by the pin.
Everything 2.6.0.beta(1+) still remains unpinned.

6 Likes

Yes, that feels less hacky for me too.

But both the 2.5.999 and 2.6.0beta0 solutions do not cover a similar case: what if an issue would be introduced in 2.6.0beta3, and it is backported to 2.5.2 ?

More edge cases, more hidden features: You can actually “unset” the pin with a blank entry:

With a compatibility file of:

        2.6.0.beta1: twofiveall
        2.4.4.beta6: ~
        2.4.2.beta1: twofourtwobetaone

Anything between 2.4.2.beta1 to 2.4.4.beta6 will not be pinned back. Anything after up to 2.6.0.beta1 will e pinned. After that will be unpinned again.

Underneath, anything that evaluates to a nil value will be untouched/be on latest. A blank entry or ~ evaluates to nil (via ruby’s yaml parser), which bypasses the pinning function.

7 Likes