diff --git a/README.md b/README.md index b9f9ab78..1a0ed7ba 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. diff --git a/gitp-garden/assets/Ceremony/2026/09/24/episode.yml b/gitp-garden/assets/Ceremony/2026/09/24/episode.yml index 3ecb1b59..3da3b0c6 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 4d4a803d..8bd29ecc 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 313d2ca7..af2b365b 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 175492fc..67d2016e 100644 --- a/rss.xml +++ b/rss.xml @@ -21,6 +21,16 @@ Codekiln codekiln@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 e3240f1f..7b3742a7 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 78b3eeb5..b26c8823 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 bcd34721..f252ff19 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 57f02fa0..c91b2b3f 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()