diff --git a/.tasks/skills.yml b/.tasks/skills.yml new file mode 100644 index 0000000..bd55f0c --- /dev/null +++ b/.tasks/skills.yml @@ -0,0 +1,38 @@ +--- +# yaml-language-server: $schema=https://taskfile.dev/schema.json + +version: "3" + +vars: + DIST_DIR: '{{.DIST_DIR | default "dist"}}' + +tasks: + skills:build: + desc: Build deterministic Aether distributions + cmds: + - ./aether distribution build --output-directory "{{.DIST_DIR}}" + + skills:publish:dry-run: + desc: Build and validate the gh skill publish payload without publishing + preconditions: + - sh: command -v gh >/dev/null 2>&1 + msg: GitHub CLI (`gh`) is required for skill publish validation. + cmds: + - task: skills:build + vars: + DIST_DIR: "{{.DIST_DIR}}" + - gh skill publish "{{.DIST_DIR}}" --dry-run + + skills:publish: + desc: Publish a tagged Aether skill release after the dry-run gate + requires: + vars: + - RELEASE_TAG + preconditions: + - sh: command -v gh >/dev/null 2>&1 + msg: GitHub CLI (`gh`) is required for skill publication. + cmds: + - task: skills:publish:dry-run + vars: + DIST_DIR: "{{.DIST_DIR}}" + - gh skill publish "{{.DIST_DIR}}" --tag "{{.RELEASE_TAG}}" diff --git a/README.md b/README.md index 2136198..96f26dc 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Machine-readable catalog and provenance: - Python 3.12+ - Git - GitHub CLI (`gh`) 2.96+ for skill publish/install flows +- Task 3 (optional) for convenience wrappers in `Taskfile.yml` Install dev dependencies: @@ -141,6 +142,16 @@ Validate publishability (no release write): gh skill publish "dist" --dry-run ``` +Optional Taskfile convenience wrappers build first and delegate to the same canonical command surface: + +```sh +task skills:build +task skills:publish:dry-run +task skills:publish RELEASE_TAG="v1.0.0" +``` + +The live Taskfile publish path requires an explicit release tag and runs the dry-run task before publishing. See `docs/taskfile-workflows.md` for direct-command equivalents and ownership boundaries. + ## 9) Install locally with GitHub CLI Install from local build output: diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..a8d2d08 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,11 @@ +--- +# yaml-language-server: $schema=https://taskfile.dev/schema.json + +version: "3" + +# Keep Taskfile as an ergonomic orchestration layer. Canonical implementation +# remains in ./aether, GitHub CLI, and the repository's versioned build scripts. +includes: + skills: + taskfile: ./.tasks/skills.yml + flatten: true diff --git a/docs/taskfile-workflows.md b/docs/taskfile-workflows.md new file mode 100644 index 0000000..62623bc --- /dev/null +++ b/docs/taskfile-workflows.md @@ -0,0 +1,62 @@ +# Aether Taskfile workflows + +Taskfile is an optional developer-experience layer over Aether's canonical command surfaces. The repository does not require Taskfile for CI, release automation, or direct use of `./aether` and GitHub CLI. + +## Build distributions + +Convenience command: + +```sh +task skills:build +``` + +Direct equivalent: + +```sh +./aether distribution build --output-directory "dist" +``` + +Override the output directory when needed: + +```sh +task skills:build DIST_DIR="/tmp/aether-dist" +``` + +## Validate a skill publication + +Preferred local convenience command: + +```sh +task skills:publish:dry-run +``` + +This performs the deterministic distribution build first and then runs: + +```sh +gh skill publish "dist" --dry-run +``` + +No release is created by the dry-run task. + +## Publish an explicit tagged release + +Publication is intentionally gated on an explicit `RELEASE_TAG` value: + +```sh +task skills:publish RELEASE_TAG="v1.2.3" +``` + +The task runs the complete `skills:publish:dry-run` path before invoking: + +```sh +gh skill publish "dist" --tag "v1.2.3" +``` + +The GitHub Actions release workflow remains the protected production publication path. This local task is an explicit convenience wrapper, not a replacement for repository release policy or environment review gates. + +## Authority boundary + +- `./aether distribution build` owns deterministic distribution generation. +- `gh skill publish` owns GitHub CLI validation/publication behavior. +- `Taskfile.yml` only sequences those commands for developer ergonomics. +- Credentials remain in the developer or CI environment; Taskfiles must never contain token values. diff --git a/tests/test_taskfile_workflows.py b/tests/test_taskfile_workflows.py new file mode 100644 index 0000000..46a1484 --- /dev/null +++ b/tests/test_taskfile_workflows.py @@ -0,0 +1,51 @@ +"""Contract tests for Aether's optional Taskfile workflow wrappers.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] + + +class TaskfileWorkflowTests(unittest.TestCase): + def test_root_taskfile_composes_skill_tasks(self) -> None: + root = yaml.safe_load((ROOT / "Taskfile.yml").read_text(encoding="utf-8")) + self.assertEqual(str(root["version"]), "3") + self.assertEqual(root["includes"]["skills"]["taskfile"], "./.tasks/skills.yml") + self.assertTrue(root["includes"]["skills"]["flatten"]) + + def test_publish_tasks_delegate_to_canonical_commands(self) -> None: + workflow = yaml.safe_load( + (ROOT / ".tasks" / "skills.yml").read_text(encoding="utf-8") + ) + tasks = workflow["tasks"] + self.assertEqual( + tasks["skills:build"]["cmds"], + ['./aether distribution build --output-directory "{{.DIST_DIR}}"'], + ) + dry_run = tasks["skills:publish:dry-run"]["cmds"] + self.assertEqual(dry_run[0]["task"], "skills:build") + self.assertEqual(dry_run[1], 'gh skill publish "{{.DIST_DIR}}" --dry-run') + publish = tasks["skills:publish"] + self.assertEqual(publish["requires"]["vars"], ["RELEASE_TAG"]) + self.assertEqual(publish["cmds"][0]["task"], "skills:publish:dry-run") + self.assertEqual( + publish["cmds"][1], + 'gh skill publish "{{.DIST_DIR}}" --tag "{{.RELEASE_TAG}}"', + ) + + def test_taskfiles_do_not_embed_credentials(self) -> None: + content = "\n".join( + path.read_text(encoding="utf-8") + for path in (ROOT / "Taskfile.yml", ROOT / ".tasks" / "skills.yml") + ).lower() + for forbidden in ("github_token:", "gh_token:", "personal_access_token:", "bearer "): + self.assertNotIn(forbidden, content) + + +if __name__ == "__main__": + unittest.main()