<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Automation on despatches</title><link>https://icle.es/tags/automation/</link><description>Recent content in Automation on despatches</description><generator>Hugo</generator><language>en</language><lastBuildDate>Tue, 15 Jul 2025 10:07:46 +0100</lastBuildDate><atom:link href="https://icle.es/tags/automation/index.xml" rel="self" type="application/rss+xml"/><item><title>Automatically link to repo at current commit</title><link>https://icle.es/2025/07/08/automatically-link-to-repo-at-current-commit/</link><pubDate>Tue, 08 Jul 2025 15:39:14 +0100</pubDate><guid>https://icle.es/2025/07/08/automatically-link-to-repo-at-current-commit/</guid><description>&lt;p>I like writing blog posts, particularly about code, and I like to link to code
on my repo from my blog post. I do this a lot. Until now, I&amp;rsquo;ve just been copying
and pasting the full link to github. However, I ran into a problem today.&lt;/p>
&lt;p>I moved a file that was referenced in a blog post. I then had to go to that blog
post and update the links - this is fine if I remember the linked blog posts -
but that&amp;rsquo;s not scalable.&lt;/p></description><content:encoded><![CDATA[<p>I like writing blog posts, particularly about code, and I like to link to code
on my repo from my blog post. I do this a lot. Until now, I&rsquo;ve just been copying
and pasting the full link to github. However, I ran into a problem today.</p>
<p>I moved a file that was referenced in a blog post. I then had to go to that blog
post and update the links - this is fine if I remember the linked blog posts -
but that&rsquo;s not scalable.</p>
<p>Also:</p>
<ul>
<li>Finding the link on GitHub, then copying and pasting is annoying</li>
<li>It worries me a little that the version they are linked to could be vastly
different from what I mention on the post.</li>
</ul>
<p>I was already trying to create permalinks using tags, but that is laborious and
error prone.</p>
<p>What if I could get Hugo to:</p>
<ul>
<li>Automatically link to GitHub if the relative link is not within <code>blog/content</code></li>
<li>What if I could get it to link it to the file at the last commit of the post.</li>
</ul>
<p>The last one is something to bear in mind. If I update a blog post, I&rsquo;ll have to
ensure that the links are still relevant.</p>
<p>Alternatively, let&rsquo;s allow an override at the page level where you can provide a
specific commit to link to:</p>
<h2 id="step-1-enable-git-info">Step 1: Enable git info</h2>
<p>To be able to get the commit of the post, we need to enable
<a href="https://gohugo.io/methods/page/gitinfo/">git info</a></p>
<p><a href="https://icle.es/hugo.toml">hugo.toml</a></p>
```toml
enableGitInfo = true
```
<h2 id="step-2-update-rendering-of-link">Step 2: Update rendering of link</h2>
<p><a href="https://icle.es/layouts/_default/_markup/render-link.html">layout/_default/_markup/render-link.html</a></p>
```gotmpl
{{- $linkPath := .Destination -}}                           {{/* e.g. "../scripts/tool.sh" */}}
{{- $currentPath := .Page.File.Path -}}                     {{/* e.g. "posts/foo.md" */}}
{{- $currentDir := path.Dir $currentPath -}}                {{/* e.g. "posts" */}}

{{- $combined := path.Join $currentDir $linkPath -}}        {{/* e.g. "posts/../scripts/tool.sh" */}}
{{- $resolved := path.Clean $combined -}}                   {{/* e.g. "scripts/tool.sh" */}}

{{- $fullRepoPath := path.Join "blog/content" $resolved -}} {{/* e.g. "blog/content/scripts/tool.sh" */}}

{{- $isInContent := strings.HasPrefix $fullRepoPath "blog/content/" -}}

{{- if $isInContent -}}
    <span class="unpublished">{{ $text }}</span>
{{- else -}}
    {{- $commit := or .Page.Params.link_commit .Page.GitInfo.Hash -}}
    <a href="https://github.com/drone-ah/wordsonsand/blob/{{ $commit }}/{{ $fullRepoPath }}" {{ with .Title }}title="{{ . }}"{{ end }}>{{ $text }}</a>
```
<h2 id="bonus-allow-per-post-commit-override">Bonus: Allow per-post commit override</h2>
<p>In the above code:</p>
```gotmpl
{{- $commit := or .Page.Params.link_commit .Page.GitInfo.Hash -}}
```
<p>The commit id is picked up from the page parameter <code>link_commit</code>, or if that
doesn&rsquo;t exist, from the last commit of the page.</p>
<p>You can therefore, set the commit to use for a post with:</p>
```yaml
link_commit: <custom-commit-to-link-to>
```
<h2 id="conclusion">Conclusion</h2>
<p>Necessity might be the mother of invention, but sometimes it takes a fine-tuned
sense of frustration to detect minor needs.</p>
]]></content:encoded></item><item><title>Projector: Keep YouTube Descriptions synced</title><link>https://icle.es/2025/07/07/projector-keep-youtube-descriptions-synced/</link><pubDate>Mon, 07 Jul 2025 20:08:21 +0100</pubDate><guid>https://icle.es/2025/07/07/projector-keep-youtube-descriptions-synced/</guid><description>&lt;p>In my &lt;a href="https://icle.es/projector-Hugo.md">previous post&lt;/a>, I used &lt;a href="https://gohugo.io/">hugo&lt;/a> to
generate correctly linked, always up to date descriptions for my YouTube Videos.&lt;/p>
&lt;p>But if I&amp;rsquo;m generating the descriptions automatically&amp;hellip; I&amp;rsquo;m hardly going to be
excited about copying and pasting them into YouTube - right? right!&lt;/p>
&lt;p>Automating this process brings up a few design choices.&lt;/p>
&lt;h2 id="planning">Planning&lt;/h2>
&lt;h3 id="which-language">Which language&lt;/h3>
&lt;p>There were a few contenders, and here&amp;rsquo;s how I thought them through:&lt;/p>
&lt;h4 id="zig">Zig&lt;/h4>
&lt;p>I’m currently learning &lt;a href="https://ziglang.org/">Zig&lt;/a>, and I love using it for my
game development. But it doesn’t yet have mature libraries for working with the
YouTube Data API - and I don’t feel like writing one. So, sadly, Zig’s out for
this one.&lt;/p></description><content:encoded><![CDATA[<p>In my <a href="https://icle.es/projector-Hugo.md">previous post</a>, I used <a href="https://gohugo.io/">hugo</a> to
generate correctly linked, always up to date descriptions for my YouTube Videos.</p>
<p>But if I&rsquo;m generating the descriptions automatically&hellip; I&rsquo;m hardly going to be
excited about copying and pasting them into YouTube - right? right!</p>
<p>Automating this process brings up a few design choices.</p>
<h2 id="planning">Planning</h2>
<h3 id="which-language">Which language</h3>
<p>There were a few contenders, and here&rsquo;s how I thought them through:</p>
<h4 id="zig">Zig</h4>
<p>I’m currently learning <a href="https://ziglang.org/">Zig</a>, and I love using it for my
game development. But it doesn’t yet have mature libraries for working with the
YouTube Data API - and I don’t feel like writing one. So, sadly, Zig’s out for
this one.</p>
<h4 id="python">Python</h4>
<p>I used Python for <a href="https://icle.es/despatches.md">despatches</a> and it was the right fit there -
good libraries for BlueSky and Reddit.</p>
<p>However, I did not enjoy the experience:</p>
<ul>
<li>
<p><code>bazel</code> was a constant struggle</p>
</li>
<li>
<p><code>poetry</code> is nice… but still a bit of a nightmare. It just makes the pain more
structured</p>
</li>
<li>
<p>Worst of all: blusky failed after reddit succeeded caused a <em>partial success</em>,
which broke the Git commit and silently caused a post to be repeated
(embarrassing!)</p>
<p>That kind of problem <em>can</em> happen in Go (nil pointer), though it wouldn’t in
Zig. But at least with Go, most handleable errors <em>stay</em> errors — they don’t
crash the whole tool.</p>
</li>
</ul>
<h4 id="java">java</h4>
<p>Sure, I could do this in Java - but I really don’t want to mess with the JVM.
And more importantly, I’m doing this for fun. Java doesn’t feel like that
anymore.</p>
<h4 id="golang">golang</h4>
<p>Not quite my favourite any more, but still a close second. It&rsquo;s <em>fast</em>, has
YouTube libraries and it somehow seems fitting that Hugo is also a go baby.</p>
<p>Even though I’m not wiring the two directly, the ecosystem fit is nice.</p>
<h2 id="overall-plan">Overall Plan</h2>
<ul>
<li>
<p>Let <code>Hugo</code> render the YouTube description as plain text</p>
</li>
<li>
<p>Traverse the <code>youtube/*.md</code> files in the source directory</p>
<ul>
<li>Skip videos that are too old to update (maybe older than 30 days?)</li>
<li>Hash the rendered output (title, description, tags, etc.)</li>
<li>Compare that hash with the one stored in the frontmatter</li>
<li>If it doesn’t match,
<ul>
<li>Update the metadata on YouTube</li>
<li>Update the hash</li>
</ul>
</li>
<li>commit and push any updates (should be only hash changes)</li>
</ul>
</li>
</ul>
<h2 id="validation">Validation</h2>
<p>One thing worth being careful about is whether the metadata is valid. We do not
want the sync to fail during its scheduled run - when it won&rsquo;t have many choices
on how to resolve it.</p>
<p>In a bid to mitigate this, we&rsquo;ll add a command to validate the source and
rendered files.</p>
<p>The validation would expect the rendered files to be generated as well, which
seems reasonable since Hugo is probably running as <code>hugo serve</code> while the
content files are being updated.</p>
```go
func validate(sourcePath string, renderedPath string) error {
	targetSourceDir, err := getTargetDir(sourcePath)
	if err != nil {
		return err
	}

	targetRenderedDir, err := getTargetDir(renderedPath)
	if err != nil {
		return nil
	}

	videos, err := findRecentVideos(targetSourceDir)
	for _, video := range videos {
		_, err := video.getDescription(targetRenderedDir)
		if err != nil {
			slog.Warn("unable to find rendered file", "file", video.renderedPath)
		}
	}
	return nil
}
```
<p>The validate function will retrieve the relevant files and check that there is a
corresponding rendered description.</p>
<p>If it errors in that process, we know that it would error out in the sync.</p>
<p>We can&rsquo;t catch errors around the API though at this stage, and that&rsquo;s
unavoidable.</p>
<h2 id="sync">Sync</h2>
<h3 id="hashing-the-description">Hashing the Description</h3>
<p>This part was surprisingly easy:</p>
```go
bdesc, err := video.getDescription(targetRenderedDir)
if err != nil {
    slog.Warn("unable to find rendered file", "file", video.renderedPath)
}

// We want to hash the contents of description
// Check with the hash in the metadata to see if it matches
hash := md5.Sum(bdesc)
strHash := hex.EncodeToString(hash[:])
```
<p>The challenge was trying to write the updated yaml frontmatter back. I was using
the <code>adrg/frontmatter</code> library to read the frontmatter, but it does not support
writing it back.</p>
<h3 id="detour-write-a-small-frontmatter-library">Detour: Write a small frontmatter Library</h3>
<p>I took a little detour to build
<a href="https://icle.es/golang/inscribe.md">inscribe, a little frontmatter library that supports reading and writing back in yaml</a>.</p>
<h2 id="auth">Auth</h2>
<p>We need the YouTube Client to have an OAuth Token, which we can retrieve by:</p>
<ul>
<li><a href="https://console.cloud.google.com/auth/clients">Create a new OAuth Client</a> -
Type of desktop is probably the easiest</li>
<li>add your user account to
<a href="https://console.cloud.google.com/auth/audience">test users</a>:</li>
<li>go to
<a href="https://accounts.google.com/o/oauth2/v2/auth?client_id=YOUR_CLIENT_ID&amp;redirect_uri=urn:ietf:wg:oauth:2.0:oob&amp;response_type=code&amp;scope=https://www.googleapis.com/auth/youtube">https://accounts.google.com/o/oauth2/v2/auth?client_id=YOUR_CLIENT_ID&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/youtube</a>
<ul>
<li>Remember to substitute your actual client_id</li>
<li>Add any other scopes you might want</li>
<li>Go through the flow steps - it&rsquo;ll warn you that the app is unreleased, which
is expected</li>
</ul>
</li>
<li>Take the code that it provides</li>
<li>Call the following curl command</li>
</ul>
```bash
curl -X POST https://oauth2.googleapis.com/token \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET \
  -d code=PASTE_THE_CODE_HERE \
  -d grant_type=authorization_code \
  -d redirect_uri=urn:ietf:wg:oauth:2.0:oob
```
<p>You should finally get something like:</p>
```json
{
  "access_token": "ya29...",
  "expires_in": 3599,
  "refresh_token": "1//0g...",
  "scope": "https://www.googleapis.com/auth/youtube",
  "token_type": "Bearer"
}
```
<p>The <code>refresh_token</code> is what you want to save / use as the <code>access_token</code> will
expire (after an hour in this example).</p>
<p>The authentication was a bit more involved with a refresh token, but the
<code>oauth2</code> library helps us out:</p>
```go
func NewYouTube(ClientId string, ClientSecret string, RefreshToken string) (YouTube, error) {

	conf := &oauth2.Config{
		ClientID:     ClientId,
		ClientSecret: ClientSecret,
		Endpoint:     google.Endpoint,
		Scopes:       []string{"https://www.googleapis.com/auth/youtube"},
	}

	// Construct a token from just the refresh token
	token := &oauth2.Token{RefreshToken: RefreshToken}

	ctx := context.Background()

	// Create an authenticated client
	httpClient := conf.Client(ctx, token)

	ytService, err := youtube.NewService(ctx, option.WithHTTPClient(httpClient))
	if err != nil {
		return YouTube{}, err
	}

	return YouTube{
		service: ytService,
	}, nil

}
```
<h2 id="updating-the-description">Updating the description</h2>
<p>Setting the description is a little more complicated because you can&rsquo;t set just
the description.</p>
<p>Everything defined in the <code>VideoSnippet</code> gets updated.</p>
<p>To support this, what we need to do is get the current snippet for the video,
then update it:</p>
```go
vListCall := ytService.Videos.List([]string{"snippet"})
vListCall = vListCall.Id(videoId)
res, err := vListCall.Do()
if err != nil {
    return err
}

if len(res.Items) != 1 {
    return fmt.Errorf("wrong number of videos returned: %d", len(res.Items))
}

ytVideo := res.Items[0]
ytVideo.Snippet.Description = desc

vUpdateCall := ytService.Videos.Update([]string{"snippet"}, ytVideo)
_, err = vUpdateCall.Do()
```
<h2 id="github-action">GitHub Action</h2>
<p>The GitHub Action is fairly straightforward, mostly a copy of the Hugo one,
then:</p>
<ul>
<li>Add Bazel</li>
<li>Run projector sync</li>
<li>Commit if changed</li>
</ul>
```yaml
- uses: bazel-contrib/setup-bazel@0.15.0
  with:
    # Avoid downloading Bazel every time.
    bazelisk-cache: true
    # Store build cache per workflow.
    disk-cache: ${{ github.workflow }}
    # Share repository cache between workflows.
    repository-cache: true
- name: Run projector sync
  env:
    GOOGLE_CLIENT_ID: ${{ secrets.PROJECTOR_GOOGLE_CLIENT_ID }}
    GOOGLE_CLIENT_SECRET: ${{ secrets.PROJECTOR_GOOGLE_CLIENT_SECRET }}
    GOOGLE_REFRESH_TOKEN: ${{ secrets.PROJECTOR_GOOGLE_REFRESH_TOKEN }}
  continue-on-error: true
  run:
    bazel run //tools/projector:projector -- sync -source blog/content/youtube
    -rendered blog/public/youtube
- name: Commit and push if changed
  run: |
    git config user.name "drone-ah bot"
    git config user.email "github.actions@drone-ah.com"

    if ! git diff --quiet; then
      git add -u
      git commit -m "auto: log youtube updates"
      git push
    else
      echo "No changes to commit"
    fi
```
<p>We also needed to upgrade one permission - <code>contents</code></p>
```yaml
permissions:
  contents: write
```
<h2 id="conclusion">Conclusion</h2>
<p>The Google/YouTube documentation was the hardest part here in that it was pretty
obtuse and hard to understand.</p>
<p>Writing a little frontmatter library was unexpected, but while it took a little
time, was straightforward.</p>
<p>Once I got a handle on that, the rest of it was pretty straightforward, partly
because I was reusing parts from before.</p>
<h2 id="links">Links</h2>
<ul>
<li><a href="https://icle.es/projector-Hugo.md">Part 1: Outputting YouTube Descriptions from Hugo</a></li>
<li><a href="https://icle.es/golang/inscribe.md">inscribe: simple frontmatter yaml library</a></li>
</ul>
]]></content:encoded></item><item><title>Generate YouTube Descriptions from Hugo</title><link>https://icle.es/2025/07/03/generate-youtube-descriptions-from-hugo/</link><pubDate>Thu, 03 Jul 2025 13:18:15 +0100</pubDate><guid>https://icle.es/2025/07/03/generate-youtube-descriptions-from-hugo/</guid><description>&lt;p>Uploading and setting up YouTube videos is fiddly. There are a lot of things to
get right - title, description, chapters, links, tags - the list goes on.&lt;/p>
&lt;p>I also want to link to and from blog posts and social posts - and making sure
those links stay in sync is a hassle.&lt;/p>
&lt;p>It gets more complicated when scheduling multiple videos.&lt;/p>
&lt;p>I wanted to make it easier&lt;/p>
&lt;p>I (at the time of writing) use hugo for this blog site, and I regularly link
from the YouTube description to a page on here. Since I want to save having to
copy and paste that link into YouTube, leveraging the CMS felt sensible.&lt;/p></description><content:encoded><![CDATA[<p>Uploading and setting up YouTube videos is fiddly. There are a lot of things to
get right - title, description, chapters, links, tags - the list goes on.</p>
<p>I also want to link to and from blog posts and social posts - and making sure
those links stay in sync is a hassle.</p>
<p>It gets more complicated when scheduling multiple videos.</p>
<p>I wanted to make it easier</p>
<p>I (at the time of writing) use hugo for this blog site, and I regularly link
from the YouTube description to a page on here. Since I want to save having to
copy and paste that link into YouTube, leveraging the CMS felt sensible.</p>
<h2 id="a-youtube-content-type">A YouTube Content Type</h2>
<p><a href="https://icle.es/archetypes/youtube.md">archetypes/youtube.md</a></p>
```yaml
---
title: "{{ replace .Name "-" " " | title }}"
publishDate: {{ .Date }}
youtubeId: ""
playlist: ""
categoryId: 20
tags: []
chapters:
  - "0:00 Intro"
links:
  - title: <title>
    url: <url>
_build:
  list: never
  render: always
  publishResources: false
sitemap: false
---
```
<ul>
<li><code>title</code>: Title for the youtube video</li>
<li><code>publishDate</code>: When should the video go live</li>
<li><code>youtubeId</code>: The video id from YouTube, used to build links</li>
<li><code>playlist</code>: Which playlist is this a part of? Used to build links</li>
<li><code>categoryId</code>:
<a href="https://mixedanalytics.com/blog/list-of-youtube-video-category-ids/">YouTube category Id</a> -
e.g. gaming</li>
<li><code>tags</code>: Used to add hashtags at the end of the video</li>
<li><code>chapters</code>: Added to description to demarcate chapters</li>
<li><code>links</code>: adds each link to the description. Can be other youtube videos</li>
<li><code>outputs</code>: needed to output in plaintext format</li>
<li><code>_build</code> and <code>sitemap</code>: prevent this file getting linked/crawled</li>
</ul>
<h3 id="cascaded-properties">Cascaded Properties</h3>
<p>We also want to prevent these pages from showing up on:</p>
<ul>
<li>List Pages</li>
<li>Sitemap</li>
</ul>
<p>We also want to prevent them from being published. We could add <code>_build</code> to each
of the <code>md</code> files, or we can cascade it (thanks to
<a href="https://discourse.gohugo.io/u/jmooring">jmooring</a> from the gohugo discourse).</p>
<p><a href="https://icle.es/youtube/_index.md">content/youtube/_index.md</a></p>
```yaml
title: "YouTube"
cascade:
  _build:
    list: never
    render: always
    publishResources: false
  sitemap: false
```
<h3 id="layout-plain-text">Layout (plain text)</h3>
<p>We need a plaintext template to render it as text</p>
<p><a href="https://icle.es/layouts/_default/single.plain.txt">layouts/_default/single.plain.txt</a></p>
```gotmpl
{{ .Content | plainify | htmlUnescape }}

{{- if .Params.links }}
Links:
{{- range .Params.links }}
{{ .title }}: {{ .url | absURL }}
{{- end }}
{{ end }}

{{- if .Params.chapters }}
{{ range .Params.chapters }}
{{- . }}
{{ end -}}
{{ end }}

{{- if .Params.tags }}
{{ range .Params.tags }}#{{ . }} {{ end }}
{{ end }}
```
<h3 id="enable-plaintext-output">Enable plaintext output</h3>
<p>We also need to define plain as an output format.</p>
<p>As far as I could see, there is no way (currently) in hugo to specify a default
output type for a <code>type</code> (i.e. youtube) of content, only a <code>kind</code> (e.g. page) of
content.</p>
<p>However, we can add this to the cascade as well:</p>
```yaml
cascade:
  outputs: ["plain"]
```
<p>I also created a <code>layouts/_default/list.plain.txt</code> file to avoid the error:</p>
<p><code>WARN  found no layout file for &quot;plain&quot; for kind &quot;section&quot;: You should create a template file which matches Hugo Layouts Lookup Rules for this combination.</code></p>
<p>The contents of this file doesn&rsquo;t really matter as we shouldn&rsquo;t be rendering or
using it.</p>
<p><a href="https://icle.es/hugo.toml">hugo.toml</a></p>
```toml
[outputFormats.plain]
	mediaType = "text/plain"
	baseName = "index"
	isPlainText = true
	isHTML = false
	noUgly = true
```
<h2 id="auto-link-to-youtube">Auto link to YouTube</h2>
<p>I&rsquo;d like to be able to link to a local markdown file, and have that resolve to
the correct YouTube URL.</p>
<h3 id="from-posts">From Posts</h3>
<p><a href="https://icle.es/layouts/_default/_markup/render-link.html">layouts/_default/_markup/render-link.html</a></p>
```gotmpl
{{- if eq $page.Type "youtube" -}}
  {{- $href = printf "https://www.youtube.com/watch?v=%s" $page.Params.youtubeId -}}
{{- else -}}
  <a href="{{ $page.RelPermalink | safeURL }}" {{ with .Title }}title="{{ . }}"{{ end }}>{{ $text }}</a>
{{- end -}}

<a href="{{ $href | safeURL }}">{{ $text }}</a>
```
<p>You know what would be nicer? If it took the user to the video in the playlist -
if playlist is defined</p>
```gotmpl
{{- if eq $page.Type "youtube" -}}
  {{- $href = printf "https://www.youtube.com/watch?v=%s" $page.Params.youtubeId -}}
  {{- with $page.Params.playlist }}
    {{- $href = printf "%s&list=%s" $href . -}}
  {{- end }}
{{- else -}}
  <a href="{{ $page.RelPermalink | safeURL }}" {{ with .Title }}title="{{ . }}"{{ end }}>{{ $text }}</a>
{{- end -}}

<a href="{{ $href | safeURL }}">{{ $text }}</a>
```
<h3 id="from-youtube-description">From YouTube Description</h3>
<p>Let&rsquo;s render links to YouTube from the <code>links</code> property:</p>
<p><a href="https://icle.es/layouts/_default/single.plain.txt">layouts/_default/single.plain.txt</a></p>
```gotmpl
{{ with .Params.links }}
Links:
{{- $this := $.Page }}
{{ range . -}}
  {{- $target := $this.GetPage .url -}}
  {{- if and $target (eq $target.Type "youtube") -}}
    {{- $href := printf "https://www.youtube.com/watch?v=%s" $target.Params.youtubeId -}}
    {{- with $target.Params.playlist -}}
      {{- $href = printf "%s&list=%s" $href . -}}
    {{- end -}}
    {{ .title }}: {{ $href }}
  {{- else if $target -}}
    {{ .title }}: {{ $target.Permalink }}
  {{- else -}}
    {{ .title }}: {{ .url | absURL }}
  {{- end }}
{{ end }}
{{ end }}
```
<h3 id="future-links">Future Links</h3>
<p>While we&rsquo;re at it, let&rsquo;s skip rendering any links that go live in the future:</p>
```gotmpl
{{- $target := $this.GetPage .url -}}
{{- if and $target (eq $target.Type "youtube") (not ($target.PublishDate.After now)) -}}
  {{- $href := printf "https://www.youtube.com/watch?v=%s" $target.Params.youtubeId -}}
  {{- with $target.Params.playlist -}}
    {{- $href = printf "%s&list=%s" $href . -}}
  {{- end -}}
  {{ .title }}: {{ $href }}
{{- else if and $target (ne $target.Type "youtube") -}}
  {{ .title }}: {{ $target.Permalink }}
{{- else if not $target -}}
  {{ .title }}: {{ .url | absURL }}
{{- end }}
```
<h3 id="lets-also-skip-draft-posts">Let&rsquo;s also skip draft posts</h3>
```gotmpl
{{- else if not $target -}}
    {{- $url := .url -}}
    {{- $isExternal := or (strings.HasPrefix $url "http") (strings.HasPrefix $url "mailto:") (strings.HasPrefix $url "#") -}}
    {{- $isTag := strings.HasPrefix $url "/tags/" -}}

    {{- if or $isExternal $isTag -}}
        {{ .title }}: {{ $url | absURL }} {{ "\n" }}
    {{- else -}}
        {{- warnf "Unresolved internal link: %q in %q" $url $this.File.Path -}}
    {{- end -}}
{{- end }}
```
<h2 id="next">Next</h2>
<p>This covers the Hugo-side of things.</p>
<p>There are two more parts, that I&rsquo;d like to happen automatically:</p>
<ul>
<li><a href="https://icle.es/projector-upload.md">Uploading the video</a></li>
<li><a href="https://icle.es/projector-sync.md">Syncing metadata</a></li>
</ul>
<h2 id="links--references">Links / References</h2>
<ul>
<li><a href="https://discourse.gohugo.io/t/generating-youtube-descriptions-using-hugo/55233/2?u=drone.ah">Suggestions from <code>jmooring</code> on hugo discourse</a></li>
<li><a href="https://gohugo.io/configuration/cascade">cascade</a></li>
</ul>
<h2 id="updates">Updates</h2>
<ul>
<li>2025-07-15: add note about skipping draft posts</li>
<li>2025-07-15: permalink file references to this commit</li>
</ul>
]]></content:encoded></item><item><title>Automated Posting to BlueSky &amp; Reddit</title><link>https://icle.es/2025/07/01/automated-posting-to-bluesky-reddit/</link><pubDate>Tue, 01 Jul 2025 10:09:47 +0100</pubDate><guid>https://icle.es/2025/07/01/automated-posting-to-bluesky-reddit/</guid><description>&lt;p>I tend to be pretty impatient and when I&amp;rsquo;m doing something, I want to just
finish it off. Unfortunately, the world works better for me when I work to its
schedule.&lt;/p>
&lt;p>Every time I finish a video for &lt;a href="https://icle.es/endeavours/shri-codes.md">shri codes&lt;/a>,
while I am still in the zone, I want to post to all the places (YouTube, BlueSky
and Reddit). However, this is usually the worst time to share these if I want to
get some decent traffic and raise awareness.&lt;/p></description><content:encoded><![CDATA[<p>I tend to be pretty impatient and when I&rsquo;m doing something, I want to just
finish it off. Unfortunately, the world works better for me when I work to its
schedule.</p>
<p>Every time I finish a video for <a href="https://icle.es/endeavours/shri-codes.md">shri codes</a>,
while I am still in the zone, I want to post to all the places (YouTube, BlueSky
and Reddit). However, this is usually the worst time to share these if I want to
get some decent traffic and raise awareness.</p>
<p>I&rsquo;ve been remembering to post on the relevant days at reasonable times, but this
process is annoying at best, interrupts flow and takes up cognitive load.</p>
<p>I wanted to automate it. I&rsquo;ve got to say that automating these two seemingly
simple tasks were rife with unexpected complexity.</p>
<p>My first challenge was trying to get <code>rule_python</code> to work, which, in the end, I
did not succeed and gave up.</p>
<p>Getting <code>pylyzer</code> to work in neovim was also a challenge - another one that I
gave up on.</p>
<p>I briefly gave up on python altogether and went with go, and I made stellar
progress until I got to the bit about actually posting to <code>BlueSky</code> - ChatGPT
had (once again) lied to me (shame on me not verifying their claims). The
library it wanted me to use was a hallucination, and did not exist. I then
realised that there was no real library for reddit integration either.</p>
<p>Back to python, and trusty <code>poetry</code> to see me through.</p>
<h2 id="scheduled-elements">Scheduled Elements</h2>
<p>There are four elements to getting scheduling to work</p>
<h3 id="youtube-scheduling">YouTube Scheduling</h3>
<p>This part was the easiest. The platform is kind enough to provide an option to
schedule release of videos, and we&rsquo;ll use that!</p>
<h3 id="scheduled-publish-for-blog">Scheduled Publish for Blog</h3>
<p><code>hugo</code> supports this out of the box. The bigger challenge was how to get GitHub
Actions to regenerate the site when relevant. In the end, I identified the
window during the week when I want to be publishing.</p>
<p>10am - 4pm Mon - Fri seemed like a decent slot. GitHub Actions though does not
support summer time. I opted for 10am - 3pm, which seemed the better option.</p>
<p>My GitHub action for publishing takes one minute to execute. If I run the action
every 30 minutes, for three hours five days a week:</p>
<p><code>2 * 5 * 5 * 4 = 200</code></p>
<p><a href="https://github.com/drone-ah/wordsonsand/blob/main/.github/workflows/hugo.yaml">.github/workflows/hugo.yaml</a></p>
```yaml
on:
  schedule:
    - cron: "*/30 12-15 * * 1-5"
  # Runs on pushes targeting the default branch
  push:
    branches:
      - main
```
<p>I will need to run a second one for the despatches (below) as well, which would
mean around 400 minutes each month - while there are no limits for public
repos - it felt a little abusive to run it every minute.</p>
<p>Once this has been running safely for a while, I&rsquo;ll consider bumping the
cadence.</p>
<h4 id="cron-is-unreliable-on-github-actions">Cron is Unreliable on GitHub Actions</h4>
<p>After I got this all ready with the two workflows set up to run on GitHub
Actions, I waited, and waited, and nothing happened.</p>
<p><a href="https://docs.github.com/en/actions/reference/events-that-trigger-workflows#schedule">GA schedule doc</a>
states:</p>
<blockquote>
<p>The schedule event can be delayed during periods of high loads of GitHub
Actions workflow runs. High load times include the start of every hour. If the
load is sufficiently high enough, some queued jobs may be dropped. To decrease
the chance of delay, schedule your workflow to run at a different time of the
hour.</p></blockquote>
<p>The
<a href="https://upptime.js.org/blog/2021/01/22/github-actions-schedule-not-working/">upptime post about GitHub Actions schedule not working</a>
includes some suggested workarounds, namely:</p>
<ul>
<li><a href="https://ifttt.com/">IFTTT</a> - seems to be limited to a maximum of hourly</li>
<li><a href="https://cloud.google.com/scheduler/docs/">Google Cloud Scheduler</a> - could be
a good solution but a bit of a sledgehammer</li>
<li><a href="https://cronhub.io/">Cronhub</a> - starts at $19/mo</li>
</ul>
<p>I also discovered:</p>
<ul>
<li><a href="https://cron-job.org/">cron-job.org</a> - haven&rsquo;t tried this yet, but looks
viable</li>
</ul>
<p>I was going to try out cron-job.org when ChatGPT suggested a simpler
alternative - a simple workflow that only triggered the relevant workflows.</p>
<p>According to ChatGPT, the more complex a workflow, the more likely it is to be
dropped. It makes sense, of course, and while I wasn&rsquo;t fully convinced, I
decided to
<a href="https://github.com/drone-ah/wordsonsand/blob/main/.github/workflows/cron.yaml">give it a go</a>.</p>
<p>It&rsquo;s only been 10 minutes, but it has completed one run already - which is
promising, but the original run also ran once.</p>
<p>I&rsquo;ll have to keep an eye on the reliability of this.</p>
<h4 id="switched-to-cron-joborg">Switched to <code>cron-job.org</code></h4>
<p>While the above strategy was OK, I wanted something more reliable, so I switched
to <a href="https://console.cron-job.org">cron-job.org</a></p>
<p>I created a new access token, restricted to the repo and with two additional
permissions:</p>
<ul>
<li>actions: read &amp; write</li>
<li>contents: read (to read the workflow file, ChatGPT suggests)</li>
</ul>
<p>I then set up a http call to:</p>
<p><code>https://api.github.com/repos/&lt;gh-username&gt;/&lt;repo-name&gt;/actions/workflows/&lt;workflow-filename&gt;/dispatches</code></p>
<ul>
<li><code>&lt;gh-username&gt;</code>: use your github username from the url</li>
<li><code>&lt;repo-name&gt;</code>: name of your repo, again from the url</li>
<li><code>&lt;workflow-filename&gt;</code>: The filename of the workflow you want to trigger</li>
</ul>
<p>To triger my hugo run, I used:
<code>https://api.github.com/repos/drone-ah/wordsonsand/actions/workflows/hugo.yaml/dispatches</code></p>
<p>Under advanced, I set the following Headers:</p>
<ul>
<li><code>Accept</code>: <code>application/vnd.github+json</code></li>
<li><code>Authorization</code>: <code>token &lt;personal-access-token&gt;</code></li>
<li><code>Content-Type</code>: <code>application/json</code></li>
<li><code>User-Agent</code>: <code>cronjob</code></li>
</ul>
<p>Set <code>Request method</code> to <code>POST</code></p>
<p><code>Request body</code>:</p>
```json
{
  "ref": "main"
}
```
<h3 id="bluesky">BlueSky</h3>
<p>This one - posting to BlueSky was far more complicated than I anticipated. All
the complexity was around its requirement to separate the post out into facets.
I recognise and value the semantic content such a process would output. However,
I could not find an algorithm or any details on how to extract the facets from
some text - e.g. markdown.</p>
<p>I referenced some code from a couple of sources for a stopgap solution to
address urls and hashtags.</p>
<p>And then, I found <a href="https://github.com/dmoggles/blueskysocial">blueskysocial</a></p>
<h3 id="reddit">Reddit</h3>
<p>You first need to <a href="https://www.reddit.com/prefs/apps/">register an app</a> on
reddit, from a page I don&rsquo;t seem to be able to get from anywhere except a direct
link.</p>
<p>Once I registered a <code>personal script</code>, which will let any of the developers
registered on that client to post, I got to try and login and was faced with:</p>
<p><code>prawcore.exceptions.OAuthException: invalid_grant error processing request</code></p>
<h4 id="red-herrings">Red Herrings</h4>
<p>I tried directly with curl:</p>
```bash
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=password&username=$USERNAME&password=$PASSWORD" \
  -A "$APP_NAME" \
  https://www.reddit.com/api/v1/access_token
```
<p>and I got a similar error:</p>
<p><code>{&quot;error&quot;: &quot;invalid_grant&quot;}</code></p>
<p>After stumbling around for a while, verifying and re-verifying the credentials,
I also set up a brand new account using password auth (mine was originally
oauth). It also returned the same error.</p>
<p>Some resources that I followed:</p>
<ul>
<li><a href="https://www.reddit.com/r/redditdev">redditdev</a></li>
<li><a href="https://github.com/reddit/reddit/wiki/OAuth2-Quick-Start-Example">OAuth2 Quick Start Example</a></li>
</ul>
<p>While I was lookin around, I noticed in tiny little letters on the page to
<a href="https://www.reddit.com/prefs/apps/">register an app</a>, when you create a new
app:</p>
<blockquote>
<p>By creating an app, you agree to Reddit&rsquo;s Developer Terms and Data Api Terms.
<strong>You must also
<a href="https://www.reddit.com/r/reddit.com/wiki/api/#wiki_read_the_full_api_terms_and_sign_up_for_usage">register to use the API</a>.</strong></p></blockquote>
<p>(Emphasis mine)</p>
<p>I followed the instructions on that page, which felt more like red tape, but
easy enough for an app that is only intended to post on a schedule.</p>
<p>Alas, this too did not help!</p>
<h4 id="final-solution">Final Solution</h4>
<p>Perhaps not surprisingly, the final solution was to not use the password, but
get a refresh token instead.</p>
<p>You can do this manually on the browser. Start by going to the following URL:</p>
<p><code>https://www.reddit.com/api/v1/authorize?client_id=YOUR_CLIENT_ID&amp;response_type=code&amp;state=xyz&amp;redirect_uri=http://localhost&amp;duration=permanent&amp;scope=identity,submit,read</code></p>
<ul>
<li><code>YOUR_CLIENT_ID</code>: Replace this with the client id from your reddit app</li>
<li><code>redirect_uri</code>: This value has (<code>http://localhost</code> in the example) has to
match the <code>redirect_uri</code> setting in your app</li>
<li><code>scope</code>: Update to the scopes you are looking for.
<a href="https://www.reddit.com/api/v1/scopes">/api/vi/scopes</a> will return the list of
valid scopes and their descriptions.</li>
<li><code>state</code>: can be any value. It&rsquo;s supposed to match in the next step</li>
</ul>
<p>The browser will then ask your permission (of the scopes you defined). If you
approve, the browser will redirect to localhost (or whatever url you define for
the redirect above).</p>
<p>This redirect will likely fail, but that&rsquo;s ok. There is one parameter in the URL
that you are looking for - <code>code</code></p>
<p>In my case, I got something like:</p>
<p><code>http://localhost:8080/?state=xyz&amp;code=RilF7XDhRTr7o7B-iov2gpdDgum5pA#_</code></p>
<p>(don&rsquo;t worry - that code isn&rsquo;t the actual one)</p>
<p>You want to take the code, but without the <code>#_</code> at the end and substitute it in
the following:</p>
```bash
curl -X POST -A "despatcher" --user "$CLIENT_ID:$CLIENT_SECRET" \
  --data "grant_type=authorization_code&code=$CODE&redirect_uri=http://localhost:8080" \
  https://www.reddit.com/api/v1/access_token
```
<ul>
<li><code>CLIENT_ID</code>: The app id from your app settings page (again)</li>
<li><code>CLIENT_SECRET</code>: The secret from you app settings page</li>
<li><code>CODE</code>: The code that was in the URL above</li>
<li><code>redirect_uri</code>: exactly the same <code>redirect_uri</code> as above, and in the app
settings</li>
</ul>
```json
{
  "access_token": "<access-token>",
  "token_type": "bearer",
  "expires_in": 86400,
  "refresh_token": "<refresh_token>",
  "scope": "read submit identity"
}
```
<ul>
<li><code>access_token</code>: You can use this to auth, but not so useful for long term use
as it will expire</li>
<li><code>refresh_token</code>: more useful as it can be used to get a new access token. Pass
to <code>praw</code></li>
</ul>
<p><a href="https://github.com/drone-ah/wordsonsand/tree/main/tools/despatcher/despatch.py">tools/despatcher/despatch.py</a></p>
```python
client_id = os.environ.get("APP_REDDIT_CLIENT_ID")
client_secret = os.environ.get("APP_REDDIT_CLIENT_SECRET")
refresh_token = os.environ.get("APP_REDDIT_REFRESH_TOKEN")

reddit = praw.Reddit(
    client_id=client_id,
    client_secret=client_secret,
    refresh_token=refresh_token,
    user_agent="despatcher",
)

print(reddit.user.me())
```
<h2 id="posting--tracking">Posting &amp; Tracking</h2>
<p>Once a post has been submitted, it is important that we log it somehow.
Otherwise, we&rsquo;ll end up posting it again (and again (and again)).</p>
<p>The cleanest solution I could think of was to update the markdown file, then
commit and push the change. This will also help to keep a log of it.</p>
<p><a href="https://github.com/drone-ah/wordsonsand/blob/main/.github/workflows/despatcher.yaml">.github/workflows/despatcher.yaml</a></p>
```yaml
- name: Commit and push if changed
  run: |
    git config user.name "drone-ah bot"
    git config user.email "github.actions@drone-ah.com"

    if ! git diff --quiet; then
      git add -u
      git commit -m "auto: log post submissions"
      git push
    else
      echo "No changes to commit"
    fi
```
<h2 id="partial-successes">Partial Successes</h2>
<p>Now, I thought I&rsquo;d covered the worst offenders for risk of repeated posting, but
I&rsquo;d missed one case.</p>
<p>What happens when something gets posted, then the script errors?</p>
<p>Well, the git commit won&rsquo;t happen - and sadly this happened to my. My apologies
to the nice folks at <a href="https://www.reddit.com/r/selfhosted/">r/selfhosted</a> who
got a handful of my posts about automated posting - eek :(</p>
<p>Embarrassment aside, it identified at least one fix - probably two. Extra
embarrassing because something like this has happened to me before - many years
ago - but you live!</p>
<p>The first update is to get GitHub Actions to carry on even if there is an error:</p>
<p><a href="https://github.com/drone-ah/wordsonsand/blob/main/.github/workflows/despatcher.yaml">.github/workflows/despatcher.yaml</a></p>
```yaml
- name: Run despatcher script
  working-directory: tools/despatcher
  continue-on-error: true
  run: poetry run ./despatch.py ../../despatches/
```
<p>The second fix it to catch any errors from the dispatchers.</p>
<p><a href="https://github.com/drone-ah/wordsonsand/tree/main/tools/despatcher/despatch.py">tools/despatcher/despatch.py</a></p>
```python
try:
    ptype = p.get("type")
    if ptype == "bluesky":
        url = post_bluesky(p)

    if ptype == "reddit":
        url = post_reddit(p)
except Exception as e:
    print(f"[ERROR] Failed to post to {ptype} for {path}: {e}")
    continue  # Skip to the next file
```
<h2 id="wrap-up">Wrap Up</h2>
<p>In the end, what I thought was a two hour job took me two days, but such is the
life of a software engineer (probably everyone).</p>
<p>I am looking forward to see how it works, and a little scared if it&rsquo;ll go off
and do random things in my name - but we&rsquo;ll see</p>
<h2 id="links">Links</h2>
<ul>
<li><a href="https://icle.es/tools/despatcher/">Code Repo</a></li>
</ul>
<h2 id="updates">Updates</h2>
<ul>
<li>2025-07-08: Switch to <code>cron-job.org</code></li>
<li>2025-07-02: Add note about GA cron unreliability</li>
<li>2025-07-02: Add details of handling partial success</li>
</ul>
]]></content:encoded></item></channel></rss>