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:
92
cmd/release/blog.go
Normal file
92
cmd/release/blog.go
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
18
website/.vitepress/blog-date.ts
Normal file
18
website/.vitepress/blog-date.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
50
website/tests/blog-date.test.mjs
Normal file
50
website/tests/blog-date.test.mjs
Normal 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'
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user