Skip to content
Merged
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
14 changes: 10 additions & 4 deletions .github/workflows/package-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ jobs:
echo "display_name=$DISPLAY_NAME" >> $GITHUB_OUTPUT
echo "tag_name=${{ steps.deck.outputs.deck_name}}/v${VERSION}" >> $GITHUB_OUTPUT

- uses: astral-sh/setup-uv@v5

- name: Generate release notes
id: notes
run: |
NOTES_FILE="$RUNNER_TEMP/release-notes.md"
./tools/release_notes.py "${{ steps.deck.outputs.deck_name }}" > "$NOTES_FILE"
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT

- name: Create Release
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
Expand All @@ -89,10 +98,7 @@ jobs:
tag_name: ${{ steps.package.outputs.tag_name }}
draft: false
prerelease: false
body: |
# ${{ steps.package.outputs.display_name }} Tarot Deck v${{ steps.package.outputs.version }}

This release contains the ${{ steps.package.outputs.display_name }} deck in the format described by the [Tarot Deck Specification](https://github.com/arcanaland/specifications).
body_path: ${{ steps.notes.outputs.notes_file }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

54 changes: 51 additions & 3 deletions tools/release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,24 @@ def load_deck(deck: str) -> tuple[dict, Tree, str]:
return meta, tree, tag


def package_file(deck: str, tree: Tree, version: str) -> str:
"""The release asset's file name."""
workflow = ".github/workflows/package-release.yml"
# old decks are just zips
if tree.ref and ".tarotdeck" not in run("git", "show", f"{tree.ref}:{workflow}", ok=True):
return f"{deck}-{version}.zip"
return f"{deck}-{version}.tarotdeck"


def card_names(tree: Tree) -> dict[str, str]:
"""Major arcana names, keyed by the two-digit card id."""
path = f"{tree.deck}/names/en.toml"
if tree.has(path):
table = tomllib.loads(tree.read(path)).get("major_arcana", {})
data = tomllib.loads(tree.read(path))
# 2.0 writes [name.card.major_arcana]; 1.0 wrote [major_arcana].
table = data.get("name", {}).get("card", {}).get("major_arcana") or data.get(
"major_arcana", {}
)
if table:
return {str(k): v for k, v in table.items()}
return {f"{i:02d}": name for i, name in enumerate(DEFAULT_MAJOR_NAMES)}
Expand Down Expand Up @@ -247,16 +260,50 @@ def contents(tree: Tree) -> str:
return "\n".join(lines)


INSTALLING = """\
## Installing


**[Tarot Canvas](https://github.com/arcanaland/tarot-canvas)**

```console
$ mkdir -p ~/.var/app/land.arcana.TarotCanvas/data/tarot/decks
$ unzip {file} -d ~/.var/app/land.arcana.TarotCanvas/data/tarot/decks{into}
```

**[`cartomancer`](https://github.com/arcanaland/cartomancer)** (and other XDG-native tools):

```console
$ mkdir -p ~/.local/share/tarot/decks
$ unzip {file} -d ~/.local/share/tarot/decks{into}
$ cartomancer list
```


> Note: To keep one library for both, symlink `~/.local/share/tarot` to `~/.var/app/land.arcana.TarotCanvas/data/tarot`."""


def installing(deck: str, file: str) -> str:
# A container holds the deck root's contents with no wrapping directory
# (DECK.md §2.4), so it unpacks into a directory named for the deck.
into = f"/{deck}" if file.endswith(".tarotdeck") else ""
return INSTALLING.format(file=file, into=into)


def licensing(meta: dict, tree: Tree, repo: str, tag: str) -> str:
lines = [f"- **License:** `{meta['license']}`"]
if copyright_ := meta.get("copyright"):
lines.append(f"- **Copyright:** {copyright_}")
if attribution := meta.get("attribution"):
lines.append(f"- **Attribution:** {attribution}")
if author := meta.get("author"):
lines.append(f"- **Artist:** {author}")
# 2.0 renamed `author` to `artist` and replaced `website` with `links`.
if artist := meta.get("artist") or meta.get("author"):
lines.append(f"- **Artist:** {artist}")
if website := meta.get("website"):
lines.append(f"- **Website:** {website}")
if links := meta.get("links"):
rendered = ", ".join(f"[{l.get('title') or l['rel']}]({l['url']})" for l in links)
lines.append(f"- **Links:** {rendered}")

ref = quote(f"refs/tags/{tag}", safe="/")
for name in meta.get("license_files", []):
Expand Down Expand Up @@ -292,6 +339,7 @@ def render(deck: str) -> tuple[str, str]:
]

parts += ["## Contents", "", contents(tree), ""]
parts += ["", installing(deck, package_file(deck, tree, str(meta["version"]))), ""]
parts += ["## License and Attribution", "", licensing(meta, tree, repo, tag), ""]
parts += [
f"Packaged per the [Tarot Deck Specification]({SPEC_URL}), "
Expand Down
70 changes: 70 additions & 0 deletions tools/test_release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# SPDX-FileCopyrightText: 2026 Adam Fidel
# SPDX-License-Identifier: MIT

from __future__ import annotations

import importlib.util
from pathlib import Path

spec = importlib.util.spec_from_file_location(
"release_notes", Path(__file__).with_name("release_notes.py")
)
release_notes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(release_notes)


class FakeTree:
def __init__(self, deck, files):
self.deck = deck
self.ref = None
self.files = files
self.paths = sorted(files)

def read(self, path):
return self.files[path]

def has(self, path):
return path in self.files


def test_installing_a_container_names_the_deck_directory():
text = release_notes.installing("aquatic-tarot", "aquatic-tarot-2.0.tarotdeck")
assert "$ unzip aquatic-tarot-2.0.tarotdeck -d ~/.local/share/tarot/decks/aquatic-tarot\n" in text
assert (
"$ unzip aquatic-tarot-2.0.tarotdeck -d "
"~/.var/app/land.arcana.TarotCanvas/data/tarot/decks/aquatic-tarot\n"
) in text


def test_installing_a_legacy_zip_unpacks_into_decks():
# The old zips wrap the deck directory themselves.
text = release_notes.installing("aquatic-tarot", "aquatic-tarot-1.0.zip")
assert "$ unzip aquatic-tarot-1.0.zip -d ~/.local/share/tarot/decks\n" in text


def test_licensing_reads_2_0_keys():
meta = {
"license": "MIT",
"artist": "A. Artist",
"links": [
{"rel": "homepage", "url": "https://example.com"},
{"rel": "buy", "url": "https://example.com/shop", "title": "Shop"},
],
}
text = release_notes.licensing(meta, FakeTree("example", {}), "o/r", "example/v2.0")
assert "- **Artist:** A. Artist" in text
assert "- **Links:** [homepage](https://example.com), [Shop](https://example.com/shop)" in text


def test_licensing_still_reads_1_0_keys():
meta = {"license": "MIT", "author": "A. Artist", "website": "https://example.com"}
text = release_notes.licensing(meta, FakeTree("example", {}), "o/r", "example/v1.0")
assert "- **Artist:** A. Artist" in text
assert "- **Website:** https://example.com" in text


def test_card_names_read_both_name_file_shapes():
v2 = FakeTree("d", {"d/names/en.toml": '[name.card.major_arcana]\n00 = "Le Mat"\n'})
v1 = FakeTree("d", {"d/names/en.toml": '[major_arcana]\n00 = "Le Mat"\n'})
assert release_notes.card_names(v2) == {"00": "Le Mat"}
assert release_notes.card_names(v1) == {"00": "Le Mat"}
Loading