From 98a159ee063c87ee08f0aa6e1c93c3d745f52a98 Mon Sep 17 00:00:00 2001
From: codekiln <140930+codekiln@users.noreply.github.com>
Date: Sun, 4 Oct 2026 14:23:19 -0400
Subject: [PATCH 1/2] feat: publish September 24 with one switch
---
.../assets/Ceremony/2026/09/24/episode.yml | 4 ++-
.../assets/Ceremony/2026/09/25/episode.yml | 1 -
.../pages/Ceremony___2026___09___24.md | 2 +-
rss.xml | 10 +++++++
scripts/build_rss.py | 30 ++++++++++++++-----
scripts/import_episode.py | 4 +--
tests/test_build_rss.py | 19 ++++++++++--
tests/test_import_episode.py | 6 ++--
8 files changed, 58 insertions(+), 18 deletions(-)
diff --git a/gitp-garden/assets/Ceremony/2026/09/24/episode.yml b/gitp-garden/assets/Ceremony/2026/09/24/episode.yml
index 3ecb1b59aa..3da3b0c69f 100644
--- a/gitp-garden/assets/Ceremony/2026/09/24/episode.yml
+++ b/gitp-garden/assets/Ceremony/2026/09/24/episode.yml
@@ -1,4 +1,3 @@
-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'
@@ -6,3 +5,6 @@ audio_url: https://f005.backblazeb2.com/file/logseq-encode-garden/GitP___A___Ses
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
diff --git a/gitp-garden/assets/Ceremony/2026/09/25/episode.yml b/gitp-garden/assets/Ceremony/2026/09/25/episode.yml
index 4d4a803d7a..8bd29eccb3 100644
--- a/gitp-garden/assets/Ceremony/2026/09/25/episode.yml
+++ b/gitp-garden/assets/Ceremony/2026/09/25/episode.yml
@@ -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'
diff --git a/gitp-garden/pages/Ceremony___2026___09___24.md b/gitp-garden/pages/Ceremony___2026___09___24.md
index 313d2ca7a3..af2b365bbf 100644
--- a/gitp-garden/pages/Ceremony___2026___09___24.md
+++ b/gitp-garden/pages/Ceremony___2026___09___24.md
@@ -1,4 +1,4 @@
-public:: false
+public:: true
date-created:: [[2026-09-24 Thu]]
type:: [[Podcast/Episode]]
- # GitP.26.09.24
diff --git a/rss.xml b/rss.xml
index 175492fc3b..67d2016eca 100644
--- a/rss.xml
+++ b/rss.xml
@@ -21,6 +21,16 @@
Codekilncodekiln@pm.me
+
+ GitP.26.09.24
+ https://codekiln.github.io/gitpa/#/page/Ceremony%2F2026%2F09%2F24
+ A MicroFreak and Launchpad session beginning with two-operator FM on an initialized patch.
+ Codekiln
+ A MicroFreak and Launchpad session beginning with two-operator FM on an initialized patch.
+ Sun, 04 Oct 2026 14:22:23 -0400
+ gitpa20260924
+
+ GitP.26.09.25
https://codekiln.github.io/gitpa/#/page/Ceremony%2F2026%2F09%2F25
diff --git a/scripts/build_rss.py b/scripts/build_rss.py
index e3240f1f4d..7b3742a77b 100644
--- a/scripts/build_rss.py
+++ b/scripts/build_rss.py
@@ -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()
@@ -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"):
@@ -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:
diff --git a/scripts/import_episode.py b/scripts/import_episode.py
index 78b3eeb575..b26c88237e 100644
--- a/scripts/import_episode.py
+++ b/scripts/import_episode.py
@@ -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})
@@ -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"])
diff --git a/tests/test_build_rss.py b/tests/test_build_rss.py
index bcd34721ba..f252ff19db 100644
--- a/tests/test_build_rss.py
+++ b/tests/test_build_rss.py
@@ -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 notes'\n"
"recorded_on: '2026-09-25'\n"
@@ -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()
diff --git a/tests/test_import_episode.py b/tests/test_import_episode.py
index 57f02fa051..c91b2b3fdb 100644
--- a/tests/test_import_episode.py
+++ b/tests/test_import_episode.py
@@ -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())
@@ -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")
@@ -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()
From 2857eb0940dd09b7fcbd017361fb1adbf63f7ca5 Mon Sep 17 00:00:00 2001
From: codekiln <140930+codekiln@users.noreply.github.com>
Date: Sun, 4 Oct 2026 14:23:59 -0400
Subject: [PATCH 2/2] docs: explain the single publication switch
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index b9f9ab7849..1a0ed7ba88 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,12 @@ Ghost in the Patch Alpha podcast
## Podcast feed
-Published episodes have an `episode.yml` under `gitp-garden/assets/Ceremony////` 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////` 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.