Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ Ghost in the Patch Alpha podcast

## Podcast feed

Published episodes have an `episode.yml` under `gitp-garden/assets/Ceremony/<year>/<month>/<day>/` and a public page under `gitp-garden/pages/`. Set `published: true` only when the episode record has its permanent GUID, publication time, public page URL, and verified MP3 enclosure URL and length. Existing records without `published: true` remain drafts.
Published episodes have an `episode.yml` under `gitp-garden/assets/Ceremony/<year>/<month>/<day>/` and a public page under `gitp-garden/pages/`. Set `public:: true` on the episode page when its RSS record has a permanent GUID, publication time, public page URL, and verified MP3 enclosure URL and length. This page property is the single publication switch for both the website and RSS; pages with `public:: false` remain drafts.

Run `mise run rss:build` after editing an episode record and commit the resulting `rss.xml`. `mise run rss:check` verifies that the committed feed matches the records. The Pages workflow checks the feed and copies it to `https://codekiln.github.io/gitpa/rss.xml` after building the Logseq site.

Production notes, Ableton inspection, transcription and media preparation live in [logseq-encode-garden](https://github.com/codekiln/logseq-encode-garden). The garden's `gitpa:episode:draft` task prepares a handoff JSON file; import that file with `mise run episode:import -- '/path/to/episode-handoff.json'`.

The handoff contains `recorded_on` (YYYY-MM-DD), `episode_title` and `description`. Optional verified media metadata must include `audio_url`, `audio_length` and `audio_type` together. The importer creates an unpublished `episode.yml` and a Logseq draft page, preserves existing editorial copy, page content and publication identity, and rejects conflicting enclosure metadata before writing. Repeated imports leave existing copy untouched. Unknown fields, including `published`, `guid` and `page`, are rejected; `gitpa` owns the final copy, page, GUID and publication time. Review the description and verify the public MP3 before setting `published: true`.
The handoff contains `recorded_on` (YYYY-MM-DD), `episode_title` and `description`. Optional verified media metadata must include `audio_url`, `audio_length` and `audio_type` together. The importer creates an unpublished `episode.yml` and a Logseq draft page, preserves existing editorial copy, page content and publication identity, and rejects conflicting enclosure metadata before writing. Repeated imports leave existing copy untouched. Unknown fields, including `published`, `guid` and `page`, are rejected; `gitpa` owns the final copy, page, GUID and publication time. Review the description and verify the public MP3 before preparing a release PR with `public:: true` on the episode page. Merging that PR publishes the episode through CI.

Install the staged-file guard with `lefthook install`. It uses the machine's `mise run secrets:scan` task, including private identity patterns. GitHub Actions runs the checked-in Secretlint rules across the public source before publishing.
4 changes: 3 additions & 1 deletion gitp-garden/assets/Ceremony/2026/09/24/episode.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
published: false
episode_title: GitP.26.09.24
description: "A MicroFreak and Launchpad session beginning with two-operator FM on an initialized patch."
recorded_on: '2026-09-24'
audio_url: https://f005.backblazeb2.com/file/logseq-encode-garden/GitP___A___Session___26___09___24-Thu___Asset___Synth___Full.mp3
audio_length: 18188163
audio_type: audio/mpeg
page: Ceremony/2026/09/24
guid: gitpa20260924
published_at: '2026-10-04T14:22:23-04:00'
page_url: https://codekiln.github.io/gitpa/#/page/Ceremony%2F2026%2F09%2F24
1 change: 0 additions & 1 deletion gitp-garden/assets/Ceremony/2026/09/25/episode.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
published: true
episode_title: GitP.26.09.25
description: MicroFreak SAWX and WaveUser preset 1910, with a Novation Launchpad sequence.
recorded_on: '2026-09-25'
Expand Down
2 changes: 1 addition & 1 deletion gitp-garden/pages/Ceremony___2026___09___24.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
public:: false
public:: true
date-created:: [[2026-09-24 Thu]]
type:: [[Podcast/Episode]]
- # GitP.26.09.24
Expand Down
10 changes: 10 additions & 0 deletions rss.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
<itunes:name>Codekiln</itunes:name>
<itunes:email>codekiln@pm.me</itunes:email>
</itunes:owner>
<item>
<title>GitP.26.09.24</title>
<link>https://codekiln.github.io/gitpa/#/page/Ceremony%2F2026%2F09%2F24</link>
<description>A MicroFreak and Launchpad session beginning with two-operator FM on an initialized patch.</description>
<itunes:author>Codekiln</itunes:author>
<itunes:summary>A MicroFreak and Launchpad session beginning with two-operator FM on an initialized patch.</itunes:summary>
<pubDate>Sun, 04 Oct 2026 14:22:23 -0400</pubDate>
<guid isPermaLink="false">gitpa20260924</guid>
<enclosure url="https://f005.backblazeb2.com/file/logseq-encode-garden/GitP___A___Session___26___09___24-Thu___Asset___Synth___Full.mp3" length="18188163" type="audio/mpeg" />
</item>
<item>
<title>GitP.26.09.25</title>
<link>https://codekiln.github.io/gitpa/#/page/Ceremony%2F2026%2F09%2F25</link>
Expand Down
30 changes: 23 additions & 7 deletions scripts/build_rss.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ def https_url(record: dict, name: str, path: Path) -> str:
return value


def public_page(path: Path) -> bool:
"""Read Logseq's page property, excluding occurrences in body blocks."""
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
if "::" not in line or line.startswith(("-", "\t", " ")):
break
key, value = line.split("::", 1)
if key.strip() == "public":
return value.strip() == "true"
return False


def load_episodes(garden: Path = GARDEN) -> list[dict]:
episodes = []
guids = set()
Expand All @@ -50,7 +63,16 @@ def load_episodes(garden: Path = GARDEN) -> list[dict]:
record = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(record, dict):
raise ValueError(f"{path}: expected a YAML mapping")
if record.get("published") is not True:
# Production records without a page reference are not RSS records.
if "page" not in record:
continue
page_name = required(record, "page", path)
if page_name.startswith("/") or ".." in page_name.split("/"):
raise ValueError(f"{path}: invalid page name")
page_path = garden / "pages" / (page_name.replace("/", "___") + ".md")
if not page_path.is_file():
raise ValueError(f"{path}: episode page is missing: {page_path}")
if not public_page(page_path):
continue

for name in ("episode_title", "description", "recorded_on", "guid", "page"):
Expand All @@ -71,12 +93,6 @@ def load_episodes(garden: Path = GARDEN) -> list[dict]:
if published_at.tzinfo is None or published_at.utcoffset() is None:
raise ValueError(f"{path}: published_at needs a timezone offset")

page_name = record["page"]
if page_name.startswith("/") or ".." in page_name.split("/"):
raise ValueError(f"{path}: invalid page name")
page_path = garden / "pages" / (page_name.replace("/", "___") + ".md")
if not page_path.is_file() or "public:: true" not in page_path.read_text(encoding="utf-8").splitlines()[:5]:
raise ValueError(f"{path}: public episode page is missing: {page_path}")
if record["guid"] in guids:
raise ValueError(f"{path}: duplicate GUID {record['guid']}")
if record["audio_url"] in audio_urls:
Expand Down
4 changes: 2 additions & 2 deletions scripts/import_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def import_episode(handoff: Path, root: Path = ROOT) -> tuple[Path, Path]:
raise ValueError("Episode record escapes the front garden")
original_record = record_path.read_bytes() if record_path.exists() else None
if original_record is None:
record = {"published": False, "episode_title": data["episode_title"],
record = {"episode_title": data["episode_title"],
"description": data["description"], "recorded_on": day.isoformat(),
"page": f"Ceremony/{day:%Y/%m/%d}"}
record.update({name: data[name] for name in MEDIA if name in data})
Expand Down Expand Up @@ -140,7 +140,7 @@ def import_episode(handoff: Path, root: Path = ROOT) -> tuple[Path, Path]:
if new_record != original_record:
changes[record_path] = new_record
if original_page is None:
if record.get("published") is True:
if record.get("guid") or record.get("published_at"):
raise ValueError("Published episode page is missing; restore the reviewed page before importing")
title = record.get("episode_title", data["episode_title"])
description = record.get("description", data["description"])
Expand Down
19 changes: 16 additions & 3 deletions tests/test_build_rss.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ def setUp(self):

def write_record(self, extra=""):
self.record.write_text(
"published: true\n"
"episode_title: 'Synth & Signal'\n"
"description: 'Sounds <and> notes'\n"
"recorded_on: '2026-09-25'\n"
Expand All @@ -46,16 +45,30 @@ def test_published_episode_renders_valid_feed(self):
self.assertEqual(rss.find(f"channel/{{{ATOM}}}link").attrib["href"], "https://codekiln.github.io/gitpa/rss.xml")
self.assertIsNotNone(rss.find(f"channel/{{{ITUNES}}}image"))

def test_legacy_record_remains_unpublished(self):
def test_production_record_without_page_is_not_a_feed_record(self):
self.record.write_text("episode_title: Ceremony\nepisode_date: '2024-12-04'\n")
self.assertEqual(load_episodes(self.garden), [])

def test_missing_public_page_blocks_publication(self):
self.write_record()
(self.garden / "pages" / "Ceremony___2026___09___25.md").unlink()
with self.assertRaisesRegex(ValueError, "public episode page is missing"):
with self.assertRaisesRegex(ValueError, "episode page is missing"):
load_episodes(self.garden)

def test_private_page_is_excluded_without_a_second_switch(self):
self.write_record()
page = self.garden / "pages" / "Ceremony___2026___09___25.md"
page.write_text("public:: false\n- Draft\n")
self.assertEqual(load_episodes(self.garden), [])
page.write_text("public:: true\n- Approved\n")
self.assertEqual(len(load_episodes(self.garden)), 1)

def test_public_marker_in_body_does_not_publish(self):
self.write_record()
page = self.garden / "pages" / "Ceremony___2026___09___25.md"
page.write_text("- Example\npublic:: true\n")
self.assertEqual(load_episodes(self.garden), [])

def test_publication_date_needs_timezone(self):
self.write_record()
original = self.record.read_text()
Expand Down
6 changes: 3 additions & 3 deletions tests/test_import_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def test_creates_unpublished_record_and_valid_lfm(self):
self.data['description'] += '\n- More listening notes.'
self.run_import()
record = yaml.safe_load(self.record.read_text())
self.assertFalse(record['published'])
self.assertNotIn('published', record)
self.assertEqual(record['description'], self.data['description'])
self.assertNotIn('guid', record)
self.assertIn('public:: false', self.page.read_text())
Expand All @@ -42,7 +42,7 @@ def test_creates_unpublished_record_and_valid_lfm(self):

def test_repeat_preserves_front_copy_page_and_publication_identity(self):
self.run_import()
self.record.write_text("# Human note\npublished: true\nepisode_title: Final title\n"
self.record.write_text("# Human note\nepisode_title: Final title\n"
"description: Final copy\nrecorded_on: '2026-09-24'\n"
"guid: permanent-guid\npublished_at: '2026-10-01T12:00:00-04:00'\n"
"page: Custom/Episode\npage_url: https://example.com/episode\n")
Expand Down Expand Up @@ -132,7 +132,7 @@ def test_invalid_existing_page_does_not_append_media(self):

def test_published_missing_page_does_not_gain_a_private_draft(self):
self.run_import()
self.record.write_text(self.record.read_text().replace('published: false', 'published: true'))
self.record.write_text(self.record.read_text() + 'guid: existing-episode\n')
self.page.unlink()
self.media()
before = self.record.read_bytes()
Expand Down
Loading