diff --git a/.github/ISSUE_TEMPLATE/product-repo-approval.yml b/.github/ISSUE_TEMPLATE/product-repo-approval.yml new file mode 100644 index 0000000..7854df8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/product-repo-approval.yml @@ -0,0 +1,85 @@ +name: Product repo approval +description: Get engineering and product sign-off on a product repo before its skills enter the catalog. +title: "[Product repo approval] " +labels: + - product-repo-approval +body: + - type: markdown + attributes: + value: | + File this **before** opening a catalog pull request. Two people have to sign off here: + + - the **engineering owner**, confirming they own the underlying product, and + - the **product manager** for that product, confirming they are comfortable with + the product repo backing these skills and that the product is in Tech Preview or GA. + + Each of them signs off by leaving a comment containing `/approve` — say as much or as + little alongside it as you like. Approvals are recorded per person: nobody can approve + on someone else's behalf, and filing this issue is not an approval. + + Once both have approved, a pull request is opened automatically adding this repo to + `.github/skill_owners.json`. Merging that pull request closes this issue. + + - type: input + id: product + attributes: + label: Product name + description: The product as it is known to customers, not the repo name. + placeholder: e.g. TraceLens + validations: + required: true + + - type: input + id: repo + attributes: + label: Product repo + description: The AMD-owned repo that holds the skills, as `owner/repo`. This is the repo being approved. + placeholder: e.g. AMD-AGI/TraceLens + validations: + required: true + + - type: textarea + id: skills + attributes: + label: Skills to add + description: One per line, as the catalog will name them. Rough names are fine if they are not settled yet. + placeholder: | + tracelens-analysis-orchestrator + validations: + required: true + + - type: input + id: engineering_owner + attributes: + label: Engineering owner + description: GitHub handle of the person who owns the underlying product. They must approve from this account, so get the handle exactly right — edit the issue if it is wrong. + placeholder: e.g. @octocat + validations: + required: true + + - type: input + id: product_manager + attributes: + label: Product manager + description: GitHub handle of the product manager who manages this product. They must approve from this account. + placeholder: e.g. @octocat + validations: + required: true + + - type: dropdown + id: status + attributes: + label: Product status + description: Approval requires Tech Preview or GA. If the product is not there yet, file this once it is. + options: + - "Tech Preview" + - "GA" + - "Neither yet — pre-release or internal only" + validations: + required: true + + - type: textarea + id: notes + attributes: + label: Notes + description: Anything the approvers should know, or context on the product's status. diff --git a/.github/scripts/record_skill_owner.py b/.github/scripts/record_skill_owner.py new file mode 100644 index 0000000..57b8637 --- /dev/null +++ b/.github/scripts/record_skill_owner.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Record an approved product repo in `.github/skill_owners.json`. + +Called by the `product-repo-approval` workflow once both the engineering owner +and the product manager named on an approval issue have approved. The workflow +puts the result up for review as a pull request, so this script only edits the +registry; it never decides whether an approval is valid. + +Registry schema: + + { + "repos": [ + { + "repo": "AMD-AGI/TraceLens", # owner/repo, unique per entry + "product": "TraceLens", # customer-facing product name + "status": "tech-preview" | "ga", # product status at approval time + "engineering_owner": "octocat", # GitHub handle, no leading @ + "product_manager": "octocat", # GitHub handle, no leading @ + "approved_in": "", + "approved_at": "2026-09-01T17:04:22Z" + } + ] + } + +Entries are keyed on `repo` and kept sorted by it. Approving a repo that is +already listed updates the existing entry in place rather than adding a second +one, so a re-approval after an ownership or status change reads as the current +truth and `approved_at` moves to the later approval. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +REPO_PATTERN = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") +STATUSES = ("tech-preview", "ga") +DEFAULT_REGISTRY = Path(__file__).resolve().parents[1] / "skill_owners.json" + + +def handle(value: str) -> str: + """Normalize a GitHub handle for storage: no leading @, no stray spaces.""" + return value.strip().lstrip("@").strip() + + +def load_registry(path: Path) -> dict: + if not path.exists(): + return {"repos": []} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as err: + raise SystemExit(f"{path} is not valid JSON: {err}") from err + if not isinstance(data, dict) or not isinstance(data.get("repos", []), list): + raise SystemExit(f"{path} must be an object with a 'repos' array.") + data.setdefault("repos", []) + return data + + +def write_registry(path: Path, data: dict) -> None: + data["repos"].sort(key=lambda entry: entry.get("repo", "").lower()) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo", required=True, help="Product repo as owner/repo.") + parser.add_argument("--product", required=True, help="Customer-facing product name.") + parser.add_argument( + "--status", required=True, choices=STATUSES, help="Product status at approval time." + ) + parser.add_argument( + "--engineering-owner", required=True, help="GitHub handle of the engineering owner." + ) + parser.add_argument( + "--product-manager", required=True, help="GitHub handle of the product manager." + ) + parser.add_argument( + "--issue", required=True, help="URL of the approval issue the sign-off happened on." + ) + parser.add_argument( + "--registry", + type=Path, + default=DEFAULT_REGISTRY, + help=f"Registry file to edit (default: {DEFAULT_REGISTRY}).", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + repo = args.repo.strip() + if not REPO_PATTERN.match(repo): + raise SystemExit(f"'{repo}' is not an owner/repo slug.") + + entry = { + "repo": repo, + "product": args.product.strip(), + "status": args.status, + "engineering_owner": handle(args.engineering_owner), + "product_manager": handle(args.product_manager), + "approved_in": args.issue.strip(), + "approved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + for field in ("product", "engineering_owner", "product_manager", "approved_in"): + if not entry[field]: + raise SystemExit(f"--{field.replace('_', '-')} cannot be empty.") + + registry = load_registry(args.registry) + existing = next( + (e for e in registry["repos"] if e.get("repo", "").lower() == repo.lower()), None + ) + if existing is None: + registry["repos"].append(entry) + print(f"Added {repo} to {args.registry.name} ({entry['status']}).") + else: + existing.update(entry) + print(f"Updated the existing {repo} entry in {args.registry.name} ({entry['status']}).") + + write_registry(args.registry, registry) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skill_owners.json b/.github/skill_owners.json new file mode 100644 index 0000000..b6a4024 --- /dev/null +++ b/.github/skill_owners.json @@ -0,0 +1,3 @@ +{ + "repos": [] +} diff --git a/.github/workflows/product-repo-approval.yml b/.github/workflows/product-repo-approval.yml new file mode 100644 index 0000000..047dff4 --- /dev/null +++ b/.github/workflows/product-repo-approval.yml @@ -0,0 +1,300 @@ +name: product-repo-approval + +# Turns a "Product repo approval" issue into a reviewable registry entry. +# +# Two people named on the issue sign off by commenting `/approve`: the +# engineering owner, who owns the underlying product, and the product manager, +# who vouches for the product repo and for the product being in Tech Preview or +# GA. This workflow only records approvals from those two accounts, so a +# `/approve` from anyone else -- including whoever filed the issue -- does +# nothing but get an explanation. +# +# Each approval is stored as a label on the issue rather than kept in this run, +# so the second one can arrive days after the first and still complete the pair. +# Once both are in, the run adds the repo to `.github/skill_owners.json` and +# opens a pull request; merging it closes the issue. +# +# Every detail comes from the issue body, which makes a mistake cheap to fix: +# correct the issue and re-approve. The registry is keyed on the repo, so the +# second pass rewrites the entry instead of adding a rival one. + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + +# Two approvals landing at once would otherwise race for the same branch. +concurrency: + group: product-repo-approval-${{ github.event.issue.number }} + cancel-in-progress: false + +env: + BASE_BRANCH: main + REGISTRY: .github/skill_owners.json + +jobs: + approve: + name: Record approval + if: >- + github.event.issue.state == 'open' && + !github.event.issue.pull_request && + contains(github.event.issue.labels.*.name, 'product-repo-approval') && + contains(github.event.comment.body, '/approve') && + github.event.comment.user.type != 'Bot' + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Record this approval + id: approval + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue = context.payload.issue; + const issue_number = issue.number; + const commenter = context.payload.comment.user.login; + const body = issue.body || ""; + + const ENG_LABEL = "eng-owner-approved"; + const PM_LABEL = "pm-approved"; + + // Issue forms render each answer as an `###