diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b396b4b..bd0c18d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,25 +1,30 @@ { - "name": "Validate Design Patterns release", + "name": "Publish Design Patterns release", "on": { - "release": { - "types": ["published"] - }, - "workflow_dispatch": {} - }, - "permissions": { - "contents": "read", - "id-token": "write" + "push": { + "tags": ["v*"] + } }, + "permissions": {}, "jobs": { - "publish": { + "validate": { "runs-on": "ubuntu-latest", - "environment": "marketplace-production", + "permissions": { + "contents": "read" + }, + "outputs": { + "version": "${{ steps.release.outputs.version }}", + "release_root": "${{ steps.release.outputs.release_root }}" + }, "steps": [ { - "uses": "actions/checkout@v4" + "uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262", + "with": { + "persist-credentials": false + } }, { - "uses": "actions/setup-python@v5", + "uses": "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065", "with": { "python-version": "3.12" } @@ -33,18 +38,47 @@ "run": "set -euo pipefail\nmkdir -p dist\npython3 scripts/release_inventory.py > dist/release-inventory.json\npython3 scripts/release_inventory.py > \"$RUNNER_TEMP/release-inventory-second.json\"\ncmp dist/release-inventory.json \"$RUNNER_TEMP/release-inventory-second.json\"\npython3 scripts/build_release.py --output \"$RUNNER_TEMP/design-patterns-first\"\npython3 scripts/build_release.py --output \"$RUNNER_TEMP/design-patterns-second\"\ndiff -qr \"$RUNNER_TEMP/design-patterns-first\" \"$RUNNER_TEMP/design-patterns-second\"\npython3 scripts/build_release.py\n" }, { - "uses": "actions/upload-artifact@v4", + "name": "Install the immutable audited publisher", + "uses": "ByteDeskAI/marketplace-publisher/.github/actions/setup-bdm@43fa98d57a0d7a52f6c79b8f72409d09a424a541", "with": { - "name": "design-patterns-release-candidate", - "path": "bytedesk-package.yaml\ndist/design-patterns\ndist/release-inventory.json\n", - "if-no-files-found": "error" + "expected-sha256": "e807b92ee362c7fe146ae0913a0caa306a9f2d6ea7798cd1c44d0bd03b785e3d" } }, { - "name": "Fail closed until bdm supports trusted-publisher OIDC", - "run": "echo \"::error title=Marketplace publication blocked::ByteDesk marketplace publication is blocked because bdm does not yet acquire GitHub Actions OIDC credentials.\"\necho \"Do not substitute a PAT, token, raw Actions JWT, or another secret.\"\nexit 1\n" + "id": "release", + "name": "Validate the exact publication candidate", + "env": { + "EXPECTED_PACKAGE": "@bytedesk/design-patterns", + "RESULT_PATH": "${{ runner.temp }}/bdm-validation.json" + }, + "run": "set -euo pipefail\numask 077\n\"$RUNNER_TEMP/bytedesk-publisher/bdm\" validate bytedesk-package.yaml > \"$RESULT_PATH\"\npython3 - <<'PY'\nimport json\nimport os\nimport re\nfrom pathlib import Path\n\ndef closed(pairs):\n result = {}\n for key, value in pairs:\n if key in result:\n raise ValueError('duplicate JSON member')\n result[key] = value\n return result\n\nraw = Path(os.environ['RESULT_PATH']).read_bytes()\nif len(raw) > (1 << 20) or not raw.endswith(b'\\n'):\n raise SystemExit('invalid validation output framing')\ndocument = json.loads(raw, object_pairs_hook=closed)\nrequired = {'schemaVersion', 'package', 'version', 'manifestDigest', 'releaseRootDigest', 'validatorRevision', 'variants'}\nif not isinstance(document, dict) or set(document) != required or document['schemaVersion'] != 1:\n raise SystemExit('unexpected validation output contract')\nversion = document['version']\nroot = document['releaseRootDigest']\nif document['package'] != os.environ['EXPECTED_PACKAGE'] or os.environ['GITHUB_REF_NAME'] != 'v' + version:\n raise SystemExit('release identity does not match tag')\nif not isinstance(version, str) or re.fullmatch(r'(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?', version) is None:\n raise SystemExit('release version is invalid')\nif not isinstance(root, str) or re.fullmatch(r'sha256:[a-f0-9]{64}', root) is None:\n raise SystemExit('release root is invalid')\nwith open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output:\n output.write(f'version={version}\\nrelease_root={root}\\n')\nPY\n" + }, + { + "uses": "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", + "with": { + "name": "bdm-publication-source", + "path": "bytedesk-package.yaml\ndist/design-patterns\n", + "include-hidden-files": true, + "if-no-files-found": "error", + "retention-days": 1 + } } ] + }, + "publish": { + "needs": "validate", + "permissions": { + "actions": "read", + "contents": "read", + "id-token": "write" + }, + "uses": "ByteDeskAI/marketplace-publisher/.github/workflows/publish-v1.yml@5c37d14b790fcb43f5f4dd1fb141950e3b1c6b07", + "with": { + "package": "@bytedesk/design-patterns", + "version": "${{ needs.validate.outputs.version }}", + "source-commit": "${{ github.sha }}", + "release-root": "${{ needs.validate.outputs.release_root }}" + } } } } diff --git a/README.md b/README.md index 5129298..fe374c7 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,22 @@ claude plugin install design-patterns@bytedesk-design-patterns codex plugin marketplace add . ``` +## Marketplace Publication + +Pushing an immutable `v` tag runs the repository validation and +deterministic staging checks, validates `bytedesk-package.yaml` with the audited +`bdm` client, and uploads the fixed `bdm-publication-source` candidate. The +workflow then calls the centrally governed ByteDesk publisher by full commit +SHA. That publisher independently compares the candidate manifest with the +tagged source, recomputes the release root, and exchanges GitHub OIDC for a +short-lived publisher identity. No PAT, repository secret, direct CLI publish, +or mutable workflow reference is used. + +The tag must equal `v` plus `metadata.version`; mismatched identities or release +roots fail before publication. The marketplace trusted-publisher binding must +authorize this repository, `.github/workflows/publish.yml`, and the exact +central reusable-workflow SHA. + ## What Is Included - A Claude Code marketplace manifest at `.claude-plugin/marketplace.json`. diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 616da9c..6aa6252 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -1,4 +1,5 @@ import json +import os import subprocess import tempfile import unittest @@ -68,25 +69,49 @@ def test_codex_mcp_uses_artifact_relative_command_without_root_cwd(self): # and invalid under codex-plugin-v1 validation. self.assertNotIn("cwd", server) - def test_publish_workflow_is_oidc_only_and_fails_closed_after_candidate_upload(self): + def test_publish_workflow_uses_immutable_validated_candidate(self): workflow_path = ROOT / ".github" / "workflows" / "publish.yml" self.assertTrue(workflow_path.is_file()) workflow = load_json(workflow_path) - self.assertEqual(workflow["permissions"], {"contents": "read", "id-token": "write"}) - publish = workflow["jobs"]["publish"] - self.assertEqual(publish["environment"], "marketplace-production") - commands = "\n".join(step.get("run", "") for step in publish["steps"]) + self.assertEqual(workflow["on"], {"push": {"tags": ["v*"]}}) + self.assertEqual(workflow["permissions"], {}) + self.assertEqual(set(workflow["jobs"]), {"validate", "publish"}) + validate = workflow["jobs"]["validate"] + self.assertEqual(validate["permissions"], {"contents": "read"}) + self.assertEqual(set(validate["outputs"]), {"version", "release_root"}) + commands = "\n".join(step.get("run", "") for step in validate["steps"]) self.assertIn("python3 scripts/release_inventory.py", commands) self.assertIn("python3 scripts/build_release.py", commands) self.assertIn("python3 scripts/validate_catalog.py", commands) self.assertIn("python3 plugins/design-patterns/scripts/validate_catalog.py", commands) self.assertIn("python3 -m unittest tests.test_release_contract", commands) - self.assertIn("exit 1", commands) - upload = next(step for step in publish["steps"] if step.get("uses") == "actions/upload-artifact@v4") - self.assertEqual(upload["with"]["name"], "design-patterns-release-candidate") - self.assertIn("bytedesk-package.yaml", upload["with"]["path"]) - self.assertIn("dist/design-patterns", upload["with"]["path"]) - self.assertIn("dist/release-inventory.json", upload["with"]["path"]) + self.assertIn('"$RUNNER_TEMP/bytedesk-publisher/bdm" validate bytedesk-package.yaml', commands) + setup = next(step for step in validate["steps"] if step.get("name") == "Install the immutable audited publisher") + self.assertEqual( + setup["uses"], + "ByteDeskAI/marketplace-publisher/.github/actions/setup-bdm@43fa98d57a0d7a52f6c79b8f72409d09a424a541", + ) + self.assertEqual(setup["with"]["expected-sha256"], "e807b92ee362c7fe146ae0913a0caa306a9f2d6ea7798cd1c44d0bd03b785e3d") + upload = next(step for step in validate["steps"] if str(step.get("uses", "")).startswith("actions/upload-artifact@")) + self.assertEqual(upload["uses"], "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02") + self.assertEqual(upload["with"]["name"], "bdm-publication-source") + self.assertEqual(upload["with"]["path"], "bytedesk-package.yaml\ndist/design-patterns\n") + self.assertIs(upload["with"]["include-hidden-files"], True) + self.assertEqual(upload["with"]["retention-days"], 1) + + publish = workflow["jobs"]["publish"] + self.assertEqual(publish["needs"], "validate") + self.assertEqual(publish["permissions"], {"actions": "read", "contents": "read", "id-token": "write"}) + self.assertEqual( + publish["uses"], + "ByteDeskAI/marketplace-publisher/.github/workflows/publish-v1.yml@5c37d14b790fcb43f5f4dd1fb141950e3b1c6b07", + ) + self.assertEqual(publish["with"], { + "package": "@bytedesk/design-patterns", + "version": "${{ needs.validate.outputs.version }}", + "source-commit": "${{ github.sha }}", + "release-root": "${{ needs.validate.outputs.release_root }}", + }) raw = workflow_path.read_text(encoding="utf-8") for forbidden in [ "secrets.", @@ -95,9 +120,47 @@ def test_publish_workflow_is_oidc_only_and_fails_closed_after_candidate_upload(s "go install", "curl ", "ACTIONS_ID_TOKEN_REQUEST", + "workflow_dispatch", + '"release":', + "exit 1", ]: self.assertNotIn(forbidden, raw) + def test_publish_validation_output_parser_binds_tag_package_and_root(self): + workflow = load_json(ROOT / ".github" / "workflows" / "publish.yml") + release = next(step for step in workflow["jobs"]["validate"]["steps"] if step.get("id") == "release") + validator = release["run"].split("python3 - <<'PY'\n", 1)[1].rsplit("\nPY\n", 1)[0] + payload = { + "schemaVersion": 1, + "package": "@bytedesk/design-patterns", + "version": VERSION, + "manifestDigest": "sha256:" + "b" * 64, + "releaseRootDigest": "sha256:" + "a" * 64, + "validatorRevision": "providers-v1", + "variants": [{"id": "claude-code"}], + } + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + result_path = root / "validation.json" + output_path = root / "github-output" + result_path.write_text(json.dumps(payload, separators=(",", ":")) + "\n", encoding="utf-8") + environment = os.environ.copy() + environment.update( + RESULT_PATH=str(result_path), GITHUB_OUTPUT=str(output_path), + GITHUB_REF_NAME="v" + VERSION, EXPECTED_PACKAGE="@bytedesk/design-patterns", + ) + result = subprocess.run(["python3", "-c", validator], env=environment, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + output_path.read_text(encoding="utf-8"), + f"version={VERSION}\nrelease_root=sha256:{'a' * 64}\n", + ) + payload["releaseRootDigest"] = "not-a-digest" + result_path.write_text(json.dumps(payload) + "\n", encoding="utf-8") + rejected = subprocess.run(["python3", "-c", validator], env=environment, text=True, capture_output=True) + self.assertNotEqual(rejected.returncode, 0) + self.assertIn("release root is invalid", rejected.stderr) + def test_source_tree_recipe_has_a_stable_inventory(self): recipe = load_json(ROOT / "packaging" / "source-tree-v1.json") self.assertEqual(recipe["id"], "source-tree-v1")