diff --git a/cmd/release/blog.go b/cmd/release/blog.go new file mode 100644 index 00000000..83c9842a --- /dev/null +++ b/cmd/release/blog.go @@ -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 +} diff --git a/cmd/release/main.go b/cmd/release/main.go index 6b54d805..3c17408a 100644 --- a/cmd/release/main.go +++ b/cmd/release/main.go @@ -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)) diff --git a/website/.vitepress/blog-date.ts b/website/.vitepress/blog-date.ts new file mode 100644 index 00000000..3ff71868 --- /dev/null +++ b/website/.vitepress/blog-date.ts @@ -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 + ); +} diff --git a/website/.vitepress/blog.data.ts b/website/.vitepress/blog.data.ts index b3b43cab..84a07972 100644 --- a/website/.vitepress/blog.data.ts +++ b/website/.vitepress/blog.data.ts @@ -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, diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 68530a9b..85b9c222 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -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(); 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}` }); } diff --git a/website/src/next/docs/releasing.md b/website/src/next/docs/releasing.md index 54c02f45..0436703d 100644 --- a/website/src/next/docs/releasing.md +++ b/website/src/next/docs/releasing.md @@ -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:` 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/ diff --git a/website/tests/blog-date.test.mjs b/website/tests/blog-date.test.mjs new file mode 100644 index 00000000..b175ed84 --- /dev/null +++ b/website/tests/blog-date.test.mjs @@ -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' + ); +});