Building the Changelog

Changelogs are pretty common, but they are often used internally instead of being used as a way to engage with your users.

It’s an easy and low-effort way to communicate your progress and celebrate wins. It keeps momentum, gathers new users and generally helps your company become more successful.

With this in mind, we decided to create a changelog for Quill and we ended up using GitHub releases as the foundation for it.

The following implementation will mainly be structured around our stack based on Laravel and Inertia, but can also be used as a starting point or reference for different stacks.

Why GitHub releases?

First and foremost, we needed a maintainable changelog.

Since we already rely on GitHub internally for our releases, we decided that it would be ideal to maintain this workflow, while also having the advantage of being able to use these releases as the foundation for our public changelog.

We just needed a way to list all the releases from our GitHub repository. Easy enough, GiHub’s Releases API offers just that, it even works on private repositories.

Fetching releases

The following endpoint from the API returns a list of releases.

GET /repos/{owner}/{repo}/releases

This is an example of the relevant parts of the response object from the previously mentioned endpoint.

[
   {
      "id": 2,
      "tag_name": "v0.2.0",
      "name": "Now in early access",
      "body": "Early access is here at last! We’ve spent the past couple days cleaning up, fixing bugs, optimizing performance and polishing rough edges ...",
      "draft": false,
      "prerelease": false,
      "created_at": "2022-10-13T13:40:30Z",
      "published_at": "2022-10-13T13:48:06Z"
   }
]

With this in place, we now need an elegant way to interact with the response data. Laravel includes Eloquent, an object-relational mapper that makes it enjoyable to interact with data. The only issue is that it only applies to data from a database.

For this problem we can rely on the very neat package called Sushi, which enables us to turn the response array into an Eloquent Collection.

Eloquent with Sushi

Sushi markets itself as the missing "array" driver for Eloquent. It’s an elegant solution without the need of dealing with a dedicated database. Lets start by installing Sushi.

$ composer require calebporzio/sushi

We may implement Sushi for our purpose by using the provided getRows() function in our release model as such.

use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Sushi\Sushi;

class Release extends Model
{
    use Sushi;
    
    public function getRows()
    {
        $response = Http::withToken('YOUR-TOKEN')
            ->accept('application/vnd.github+json')
            ->get('https://api.github.com/repos/{owner}/{repo}/releases');
    
        $releases = $response->collect();
        $releases = $releases->map(function ($release) {
            $release['slug'] = Str::slug(Carbon::parse($release['published_at'])
                ->toDateString(), '-');
    
            return collect($release)->only([
                'slug',
                'tag_name',
                'name',
                'body',
                'published_at'
            ]);
        });
    
        return $releases->toArray();
    }
}

What we’re doing here is basically fetching the data from the GitHub API and then we’re mapping it to only return the items we actually need. Sushi then magically handles all the logic for turning it into Eloquent for us.

Additionally, we may want to add some global scope logic to sort the releases by their published date.

protected $casts = [
    'published_at' => 'date',
];
 
protected static function boot()
{
    parent::boot();

    static::addGlobalScope('order', function ($query) {
        $query->orderBy('published_at', 'desc');
    });
}

While this is now already at a point where it could be considered done, we are lacking some necessary features for our intended purpose. We want to be able to display a feature image and a release summary, which is also useful for listing multiple releases.

GitHub relies on Markdown as a markup language to format releases and unfortunately it doesn’t support feature images or summaries.

Frontmatter to the rescue, combined with Markdown it solves our issues.

Parsing Frontmatter

Frontmatter enables us to add complexity to our content. It’s content has to be made up of YAML at the very top of the body and must begin and end with three dashes --- as such.


---

feature_image: https://cdn.quill.do/images/changelog-true-fan.png
summary: Early access is here at last! We’ve spent the past couple days cleaning up, fixing bugs, optimizing performance and polishing rough edges.

---

With the markup added to our release, we now need a way to parse it in Laravel. Thankfully, Spatie provides a package that parses Frontmatter for us. Lets progress by installing it.

$ composer require spatie/yaml-front-matter

By updating our previous code block in the getRows() function within our Release model with the following code, we may now handle feature images and summaries.

use Spatie\YamlFrontMatter\YamlFrontMatter;

...

$yaml = YamlFrontMatter::parse($release['body']);

$release['body'] = $yaml->body();
$release['feature_image'] = $yaml->feature_image;
$release['summary'] = $yaml->summary ?: ''

return collect($release)->only(['slug', 'feature_image', 'tag_name', 'name', 'summary', 'body', 'published_at']);

While this could’ve been automated by parsing the first paragraph from our release body and similarly parsing the first image. With this approach and implementation we get more control over the process, which is ideal in this situation. Ultimately we could’ve combined both solutions and only provide the automation in case the manual method is omitted.

Now we need to provide the releases through a controller to expose it to our frontend. Fortunately this is very easy to do because of Sushi, by simply interacting with the collection. We may also provide previous releases besides the active slug.

use App\Models\Release;
use Inertia\Inertia;

class ChangelogController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @param  string  $slug
     * @return \Illuminate\Http\Response
     */
    public function index($slug = null)
    {
        $release = $slug ? Release::whereSlug($slug)->first() : Release::first();
        $previous_releases = Release::where('published_at', '<', $release['published_at'])->get();

        return Inertia::render('Changelog', [
            'release' => $release,
            'previousReleases' => $previous_releases,
            'slug' => $slug,
        ]);
    }
}

With the controller in place, that completes the backend logic for this implementation.

We now need to be able to parse the Markdown that is provided to the frontend through the controller route.

use App\Http\Controllers\ChangelogController;

Route::get('/changelog/{slug?}', [ChangelogController::class, 'index'])
        ->name('changelog');

Parsing Markdown

When it comes to parsing the Markdown, the implementation could end up looking very different depending on your stack, therefor we decided to not go too deep into the details of how we ended up handling it.

The Markdown could be parsed either directly on the backend or the frontend. We decided to parse ours on the frontend because we wanted it to be able to rely on our component library. While that would’ve been possible to do with backend parsing as well, it ended up being easier for us to do the with the former.

We ended up using Marked to parse our Markdown on the frontend. We also extended it to support embedded components.

We might write a blog post going into further detail about our Marked implementation in the future.

End result

We’re very satisfied with the end result. We’ve ended up with an implementation that is easy to maintain and that doesn’t compromise with the result.

In the future we might potentially build and improve upon it, but for now it handles every use case we may throw at it. We also ended up adding an RSS feed, an implementation we will certainly go into further detail in a blog post in the future.

Hopefully this inspires you to create your own changelog. If you end up creating one with a similar implementation after reading this, let us know by tweeting us at @CraftQuill

View our changelog

Share this release