1
0
mirror of https://github.com/go-task/task.git synced 2026-10-23 11:48:55 +03:00

release: automatically date new blog posts (#3056)

This commit is contained in:
Valentin Maerten
2026-10-01 22:00:48 +02:00
committed by GitHub
parent 72680fa43c
commit 7a8121af90
7 changed files with 213 additions and 20 deletions

92
cmd/release/blog.go Normal file
View File

@@ -0,0 +1,92 @@
package main
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"go.yaml.in/yaml/v3"
)
// Validate every new post before writing any of them or promoting the website.
func dateNewBlogPosts(next, latest, date string) error {
type update struct {
path string
data string
}
var updates []update
err := filepath.WalkDir(next, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || entry.Name() == "index.md" || filepath.Ext(path) != ".md" {
return nil
}
rel, err := filepath.Rel(next, path)
if err != nil {
return err
}
if _, err := os.Stat(filepath.Join(latest, rel)); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
data, err := os.ReadFile(path) //nolint:gosec // Release inputs are trusted files in the local checkout.
if err != nil {
return err
}
content, err := blogPostDate(string(data), date)
if err != nil {
return fmt.Errorf("%s: invalid frontmatter: %w", path, err)
}
if content == string(data) {
return nil
}
updates = append(updates, update{path, content})
return nil
})
if err != nil {
return err
}
for _, update := range updates {
if err := os.WriteFile(update.path, []byte(update.data), 0o644); err != nil { //nolint:gosec
return err
}
fmt.Printf("Updated blog date: %s → %s\n", update.path, date)
}
return nil
}
var (
frontmatterDelimiter = regexp.MustCompile(`(?m)^---[\t ]*\r?$`)
// Include indented continuation lines if the previous date was a YAML block.
blogDateField = regexp.MustCompile(`(?m)^(?:date|'date'|"date"):[^\r\n]*(?:\r?\n[\t ]+[^\r\n]*)*`)
)
func blogPostDate(data, date string) (string, error) {
delimiters := frontmatterDelimiter.FindAllStringIndex(data, 2)
if len(delimiters) != 2 || delimiters[0][0] != 0 {
return "", fmt.Errorf("expected opening and closing --- delimiters")
}
frontmatter := data[delimiters[0][1]:delimiters[1][0]]
var metadata map[string]any
if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil {
return "", err
}
if blogDateField.MatchString(frontmatter) {
frontmatter = blogDateField.ReplaceAllString(frontmatter, "date: "+date)
} else if _, exists := metadata["date"]; exists {
return "", fmt.Errorf("expected date on its own unindented line")
} else {
frontmatter += "date: " + date + "\n"
}
// Reject unusual YAML layouts that cannot accept a standalone date line.
metadata = nil
if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil {
return "", err
}
// Keep the closing delimiter and everything after it verbatim.
return data[:delimiters[0][1]] + frontmatter + data[delimiters[1][0]:], nil
}

View File

@@ -86,7 +86,15 @@ func release() error {
return nil
}
if err := changelog(version); err != nil {
return prepareRelease(version, time.Now().Format(time.DateOnly))
}
func prepareRelease(version *semver.Version, date string) error {
if err := dateNewBlogPosts("website/src/next/blog", "website/src/latest/blog", date); err != nil {
return err
}
if err := changelog(version, date); err != nil {
return err
}
@@ -148,7 +156,7 @@ func bumpVersion(version *semver.Version, verb string) error {
return nil
}
func changelog(version *semver.Version) error {
func changelog(version *semver.Version, date string) error {
// Open changelog target file
b, err := os.ReadFile(changelogTarget)
if err != nil {
@@ -169,7 +177,6 @@ func changelog(version *semver.Version) error {
return err
}
changelog := string(b)
date := time.Now().Format("2006-01-02")
// Replace "Unreleased" with the new version and date
changelog = changelogReleaseRegex.ReplaceAllString(changelog, fmt.Sprintf("## v%s - %s", version, date))

View File

@@ -0,0 +1,18 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
export function createBlogDateResolver(
channel: string,
latestBlogDir: string,
now = new Date()
) {
const buildDate = now.toISOString().slice(0, 10);
return (file: string, storedDate: string): Date =>
new Date(
channel === 'next' &&
file !== 'index.md' &&
!existsSync(resolve(latestBlogDir, file))
? buildDate
: storedDate
);
}

View File

@@ -1,4 +1,6 @@
import { createContentLoader } from 'vitepress';
import { fileURLToPath } from 'node:url';
import { createBlogDateResolver } from './blog-date';
export interface Post {
title: string;
@@ -32,6 +34,10 @@ function extractExcerpt(html: string): string | undefined {
// Same channel as .vitepress/config.ts: the posts of the other one are not part
// of this build.
const channel = process.env.DOCS_CHANNEL === 'latest' ? 'latest' : 'next';
const blogDate = createBlogDateResolver(
channel,
fileURLToPath(new URL('../src/latest/blog/', import.meta.url))
);
export default createContentLoader(`${channel}/blog/*.md`, {
render: true,
@@ -45,7 +51,8 @@ export default createContentLoader(`${channel}/blog/*.md`, {
}))
.filter(({ url }) => url !== '/blog/')
.map(({ frontmatter, html, url }) => {
const date = new Date(frontmatter.date);
const file = url.slice('/blog/'.length).replace(/\.html$/, '') + '.md';
const date = blogDate(file, frontmatter.date);
return {
title: frontmatter.title,
url,

View File

@@ -18,6 +18,7 @@ import { fileURLToPath, URL } from 'node:url';
import llmstxt from 'vitepress-plugin-llms';
import { sidebar as nextSidebar } from './sidebar/next.ts';
import { sidebar as latestSidebar } from './sidebar/latest.ts';
import { createBlogDateResolver } from './blog-date';
const version = readFileSync(
resolve(__dirname, '../../internal/version/version.txt'),
@@ -41,6 +42,10 @@ const docsSidebar = isLatest ? latestSidebar : nextSidebar;
// Builds the "/blog/" sidebar from each blog post's frontmatter.
function buildBlogSidebar() {
const blogDir = resolve(__dirname, `../src/${channel}/blog`);
const blogDate = createBlogDateResolver(
channel,
resolve(__dirname, '../src/latest/blog')
);
const posts = readdirSync(blogDir)
.filter((file) => file.endsWith('.md') && file !== 'index.md')
.map((file) => {
@@ -50,14 +55,14 @@ function buildBlogSidebar() {
return {
slug: file.replace(/\.md$/, ''),
title: frontmatter.sidebarTitle ?? frontmatter.title,
date: new Date(frontmatter.date)
date: blogDate(file, frontmatter.date)
};
})
.sort((a, b) => b.date.getTime() - a.date.getTime());
const byYear = new Map<number, { text: string; link: string }[]>();
for (const post of posts) {
const year = post.date.getFullYear();
const year = post.date.getUTCFullYear();
if (!byYear.has(year)) byYear.set(year, []);
byYear.get(year)!.push({ text: post.title, link: `/blog/${post.slug}` });
}

View File

@@ -22,20 +22,34 @@ The body of the GitHub release is the section of the `CHANGELOG.md` matching the
version being released, extracted by `go run ./cmd/release --notes`. A version
without changelog entries is released with an empty body.
Raw executables can also be reproduced and verified locally by
checking out a specific tag and calling `goreleaser build`, using the Go version
defined in the above GitHub Actions.
Raw executables can also be reproduced and verified locally by checking out a
specific tag and calling `goreleaser build`, using the Go version defined in the
above GitHub Actions.
## Website
`task release:<version>` promotes the documentation before tagging: the docs in
`website/src/next/docs`, their sidebar and the `next-*` JSON schemas are copied over
their published counterparts, so the released tag carries the docs of the
`website/src/next/docs`, their sidebar and the `next-*` JSON schemas are copied
over their published counterparts, so the released tag carries the docs of the
version it ships. The release workflow then runs `task website:deploy:prod`.
A new blog post is a Markdown file in `website/src/next/blog` with no file at
the same relative path in `website/src/latest/blog`, excluding `index.md`.
Renaming a post therefore makes it new. On the `next` site, new posts display
the build date in UTC (`YYYY-MM-DD`) and are sorted using that date, without
changing the source files. The `latest` site uses only the stored dates.
During release preparation, `cmd/release` replaces or adds the `date` in each
new post's frontmatter with the same local date used for the changelog. It
validates all new posts' frontmatter before writing; an error identifies the
file to fix. It then copies the blog to `latest`, so both copies carry the same
date in the release commit. This is the preparation date, even if publication on
GitHub happens later. Already published posts keep their dates, including when
their content changes. The `--version` and `--notes` flags do not write files.
Because taskfile.dev is built from the latest copy, it can be redeployed at any
time between releases - to publish a blog post or a documentation fix -
without exposing the docs of unreleased features:
time between releases - to publish a blog post or a documentation fix - without
exposing the docs of unreleased features:
```shell
git checkout main && git pull
@@ -46,11 +60,11 @@ task website:deploy:prod
GoReleaser will automatically publish the release to most package managers:
* Cloudsmith (DEB and RPM repositories)
* Homebrew
* npm
* Snapcraft
* winget
- Cloudsmith (DEB and RPM repositories)
- Homebrew
- npm
- Snapcraft
- winget
Once the release is published, GoReleaser announces it on the
[Discord server](https://discord.gg/6TY36E39UK). Nightly releases are not
@@ -58,7 +72,7 @@ announced.
These package managers are updated automatically by the community:
* [Scoop](https://github.com/ScoopInstaller/Main/blob/master/bucket/task.json)
* [Nix](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/go/go-task/package.nix)
- [Scoop](https://github.com/ScoopInstaller/Main/blob/master/bucket/task.json)
- [Nix](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/go/go-task/package.nix)
[goreleaser]: https://goreleaser.com/

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createBlogDateResolver } from '../.vitepress/blog-date.ts';
test('preview dates use one UTC day for new posts and preserve published dates', (t) => {
const latest = mkdtempSync(join(tmpdir(), 'task-blog-'));
t.after(() => rmSync(latest, { recursive: true, force: true }));
mkdirSync(join(latest, 'nested'));
writeFileSync(join(latest, 'published.md'), 'Published content');
writeFileSync(join(latest, 'nested/published.md'), 'Published content');
const date = createBlogDateResolver(
'next',
latest,
new Date('2026-09-27T23:30:00-02:00')
);
for (const stored of [undefined, '2000-01-01']) {
assert.equal(
date('new.md', stored).toISOString(),
'2026-09-28T00:00:00.000Z'
);
}
for (const file of ['published.md', 'nested/published.md', 'index.md']) {
assert.equal(
date(file, '2024-05-09').toISOString(),
'2024-05-09T00:00:00.000Z'
);
}
const posts = ['published.md', 'new.md'].map((file) => ({
file,
time: date(file, '2024-05-09').getTime()
}));
posts.sort((a, b) => b.time - a.time);
assert.equal(posts[0].file, 'new.md');
});
test('latest always uses the stored date', () => {
const date = createBlogDateResolver(
'latest',
'/unused',
new Date('2026-09-27')
);
assert.equal(
date('post.md', '2024-05-09').toISOString(),
'2024-05-09T00:00:00.000Z'
);
});