From a74ddd7fa6da59e579dd307ca2a2fced48202235 Mon Sep 17 00:00:00 2001 From: Tester Date: Sun, 12 Jul 2026 22:18:34 +0200 Subject: [PATCH 1/2] fix(release): exclude agent-view relay symlink dirs from the source archive ATR's upload validator rejects the .claude/skills / .github/skills / .kiro/skills relay symlinks (they chain through .agents/skills/*, which a safe extractor reads as a target outside the entry). Export-ignore the relay dirs, keep the single-hop .agents/skills/* view (resolves to real skills/*), and repoint the template + spec references from .claude/skills to .agents/skills so they resolve in the archive. --- .gitattributes | 41 ++++++++++++------- .../_template/committer-onboarding-config.md | 2 +- projects/_template/security-intake-config.md | 8 ++-- tools/spec-loop/specs/mentoring-mode.md | 8 ++-- tools/spec-loop/specs/pr-management-family.md | 10 ++--- .../specs/release-management-lifecycle.md | 20 ++++----- tools/spec-loop/specs/repo-health-family.md | 10 ++--- tools/spec-loop/specs/reviewer-routing.md | 4 +- tools/spec-loop/specs/skill-reconciler.md | 4 +- 9 files changed, 60 insertions(+), 47 deletions(-) diff --git a/.gitattributes b/.gitattributes index b03ee42f..06c767a9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,18 +21,25 @@ # paths below are kept out of the signed source .zip that the [VOTE] # votes on. Keep this list to VCS/CI/editor metadata — never source. # -# IMPORTANT — do NOT export-ignore a directory that shipped files -# still reference (by markdown link, `test -f`, or symlink). The -# committed agent-view symlink dirs (`.claude/skills/`, `.github/skills/`, -# `.kiro/skills/`) relay through `.agents/skills/*` → `skills/*`, and -# specs test those paths, so `.agents/` MUST ship or every relay dangles -# in the archive. Likewise `.github/ISSUE_TEMPLATE/` and -# `.github/PULL_REQUEST_TEMPLATE.md` are linked by skills, and -# `projects/_template/.gitignore` is example content — so only the true -# CI/dev entries under `.github/` are stripped, and `.gitignore` is -# anchored to the repo root. `release-verify-rc` re-checks the unpacked -# tarball (symlink-lint + validators + no-.pyc) so a regression here -# fails the RC before the [VOTE]. +# IMPORTANT — symlinks in the archive. The committed agent-view dirs are +# regenerable adoption wiring, not source. `.agents/skills/*` are the +# canonical view (single-hop symlinks straight to the real `skills/*`), +# and the relay dirs `.claude/skills/`, `.github/skills/`, `.kiro/skills/` +# just chain through `.agents/skills/*`. A safe extractor (ATR's upload +# validator) rejects a symlink whose target is itself a symlink (the +# relay chain reads as a target "outside" the entry) — so the relay dirs +# are export-ignored here, leaving only the single-hop `.agents/skills/*` +# links (which resolve to real `skills/*` dirs). Shipped files that used +# to link the relay view (`projects/_template/*.md`) point at +# `.agents/skills/*` instead. Keep `.agents/` in the release: dropping it +# too would strip the one resolvable view and orphan those links. +# +# `.github/ISSUE_TEMPLATE/` and `.github/PULL_REQUEST_TEMPLATE.md` are +# linked by skills, and `projects/_template/.gitignore` is example +# content, so only the true CI/dev entries under `.github/` are stripped +# and `.gitignore` is anchored to the repo root. `release-verify-rc` +# re-checks the unpacked tarball (symlink-lint + validators + no-.pyc) so +# a regression here fails the RC before the [VOTE]. # Root-only VCS / dev-tool metadata (anchored so identically-named files # deeper in the tree — e.g. projects/_template/.gitignore — still ship). @@ -49,8 +56,14 @@ # Editor metadata. .idea/ export-ignore +# Regenerable agent-view relay symlink dirs — chains through `.agents/` +# that a safe extractor rejects. `.agents/skills/*` (single-hop → real +# `skills/*`) is kept; these relays are not. +.claude/skills/ export-ignore +.github/skills/ export-ignore +.kiro/skills/ export-ignore + # CI / bot config only. The rest of `.github/` (ISSUE_TEMPLATE, -# PULL_REQUEST_TEMPLATE.md, and the skills relay) is referenced by -# shipped skills and resolves via `.agents/`, so it stays in the release. +# PULL_REQUEST_TEMPLATE.md) is referenced by shipped skills, so it stays. .github/workflows/ export-ignore .github/dependabot.yml export-ignore diff --git a/projects/_template/committer-onboarding-config.md b/projects/_template/committer-onboarding-config.md index 9020781b..7b0a37ad 100644 --- a/projects/_template/committer-onboarding-config.md +++ b/projects/_template/committer-onboarding-config.md @@ -249,5 +249,5 @@ committer_governance_maintainer_roster: - [`contributor-nomination-config.md`](contributor-nomination-config.md) — nomination-brief thresholds and assessment window. - [`pmc-roster.md`](pmc-roster.md) — PMC-member roster for vote tally. -- [`committer-onboarding`](../../.claude/skills/magpie-committer-onboarding/SKILL.md) +- [`committer-onboarding`](../../.agents/skills/magpie-committer-onboarding/SKILL.md) — the skill that reads this configuration. diff --git a/projects/_template/security-intake-config.md b/projects/_template/security-intake-config.md index 74c45c9c..6aeb32d4 100644 --- a/projects/_template/security-intake-config.md +++ b/projects/_template/security-intake-config.md @@ -234,13 +234,13 @@ disclosure_governance: rating reference, and public security policy URL. - [`canned-responses.md`](canned-responses.md) — reporter-facing reply templates shaped by the `reporter_acknowledgement_model` declared here. -- [`security-issue-import`](../../.claude/skills/magpie-security-issue-import/SKILL.md) +- [`security-issue-import`](../../.agents/skills/magpie-security-issue-import/SKILL.md) — reads `security_inbox.kind`, `forwarders.enabled`, and the `disclosure_governance` block for the acknowledgement step. -- [`security-cve-allocate`](../../.claude/skills/magpie-security-cve-allocate/SKILL.md) +- [`security-cve-allocate`](../../.agents/skills/magpie-security-cve-allocate/SKILL.md) — reads `cve_authority.tool` and `governance.cve_allocation_gate`. -- [`security-issue-sync`](../../.claude/skills/magpie-security-issue-sync/SKILL.md) +- [`security-issue-sync`](../../.agents/skills/magpie-security-issue-sync/SKILL.md) — reads `disclosure_governance.window_days`, `grace_period_days`, and `pre_announce_distributors` for stale-window and pre-announcement checks. -- [`security-issue-import-via-forwarder`](../../.claude/skills/magpie-security-issue-import-via-forwarder/SKILL.md) +- [`security-issue-import-via-forwarder`](../../.agents/skills/magpie-security-issue-import-via-forwarder/SKILL.md) — reads `forwarders.enabled` and the per-adapter config in `project.md`. diff --git a/tools/spec-loop/specs/mentoring-mode.md b/tools/spec-loop/specs/mentoring-mode.md index 5f44b4bf..8f900e6d 100644 --- a/tools/spec-loop/specs/mentoring-mode.md +++ b/tools/spec-loop/specs/mentoring-mode.md @@ -122,10 +122,10 @@ a project can offer a first-time contributor. ```bash test -f docs/mentoring/spec.md -test -f .claude/skills/magpie-good-first-issue-author/SKILL.md -test -f .claude/skills/magpie-mentoring-welcome/SKILL.md -test -f .claude/skills/magpie-contributor-to-committer/SKILL.md -test -f .claude/skills/magpie-good-first-issue-sweep/SKILL.md +test -f .agents/skills/magpie-good-first-issue-author/SKILL.md +test -f .agents/skills/magpie-mentoring-welcome/SKILL.md +test -f .agents/skills/magpie-contributor-to-committer/SKILL.md +test -f .agents/skills/magpie-good-first-issue-sweep/SKILL.md uv run --project tools/skill-and-tool-validator --group dev skill-and-tool-validate uv run --project tools/skill-evals skill-eval tools/skill-evals/evals/good-first-issue-author/ uv run --project tools/skill-evals skill-eval tools/skill-evals/evals/mentoring-welcome/ diff --git a/tools/spec-loop/specs/pr-management-family.md b/tools/spec-loop/specs/pr-management-family.md index bc868f89..1e34273f 100644 --- a/tools/spec-loop/specs/pr-management-family.md +++ b/tools/spec-loop/specs/pr-management-family.md @@ -144,11 +144,11 @@ is listed here for navigability since its domain is PR threads. ## Validation ```bash -test -f .claude/skills/magpie-pr-management-triage/SKILL.md -test -f .claude/skills/magpie-pr-management-stats/SKILL.md -test -f .claude/skills/magpie-pr-management-code-review/SKILL.md -test -f .claude/skills/magpie-pr-management-quick-merge/SKILL.md -test -f .claude/skills/magpie-pr-management-mentor/SKILL.md +test -f .agents/skills/magpie-pr-management-triage/SKILL.md +test -f .agents/skills/magpie-pr-management-stats/SKILL.md +test -f .agents/skills/magpie-pr-management-code-review/SKILL.md +test -f .agents/skills/magpie-pr-management-quick-merge/SKILL.md +test -f .agents/skills/magpie-pr-management-mentor/SKILL.md test -f docs/pr-management/README.md uv run --project tools/skill-and-tool-validator --group dev skill-and-tool-validate ``` diff --git a/tools/spec-loop/specs/release-management-lifecycle.md b/tools/spec-loop/specs/release-management-lifecycle.md index ce18efdd..6edad6f3 100644 --- a/tools/spec-loop/specs/release-management-lifecycle.md +++ b/tools/spec-loop/specs/release-management-lifecycle.md @@ -154,16 +154,16 @@ code lands. test -f docs/release-management/spec.md test -f docs/release-management/process.md test -f projects/_template/release-management-config.md -test -f .claude/skills/magpie-release-prepare/SKILL.md -test -f .claude/skills/magpie-release-keys-sync/SKILL.md -test -f .claude/skills/magpie-release-rc-cut/SKILL.md -test -f .claude/skills/magpie-release-vote-draft/SKILL.md -test -f .claude/skills/magpie-release-archive-sweep/SKILL.md -test -f .claude/skills/magpie-release-audit-report/SKILL.md -test -f .claude/skills/magpie-release-announce-draft/SKILL.md -test -f .claude/skills/magpie-release-verify-rc/SKILL.md -test -f .claude/skills/magpie-release-vote-tally/SKILL.md -test -f .claude/skills/magpie-release-promote/SKILL.md +test -f .agents/skills/magpie-release-prepare/SKILL.md +test -f .agents/skills/magpie-release-keys-sync/SKILL.md +test -f .agents/skills/magpie-release-rc-cut/SKILL.md +test -f .agents/skills/magpie-release-vote-draft/SKILL.md +test -f .agents/skills/magpie-release-archive-sweep/SKILL.md +test -f .agents/skills/magpie-release-audit-report/SKILL.md +test -f .agents/skills/magpie-release-announce-draft/SKILL.md +test -f .agents/skills/magpie-release-verify-rc/SKILL.md +test -f .agents/skills/magpie-release-vote-tally/SKILL.md +test -f .agents/skills/magpie-release-promote/SKILL.md uv run --project tools/skill-and-tool-validator --group dev skill-and-tool-validate uv run --project tools/skill-evals skill-eval tools/skill-evals/evals/release-announce-draft/ ``` diff --git a/tools/spec-loop/specs/repo-health-family.md b/tools/spec-loop/specs/repo-health-family.md index d0aac134..c40bf558 100644 --- a/tools/spec-loop/specs/repo-health-family.md +++ b/tools/spec-loop/specs/repo-health-family.md @@ -120,11 +120,11 @@ grouped, prioritised report → wait for confirmation before any write. ## Validation ```bash -test -f .claude/skills/magpie-ci-runner-audit/SKILL.md -test -f .claude/skills/magpie-workflow-security-audit/SKILL.md -test -f .claude/skills/magpie-dependency-audit/SKILL.md -test -f .claude/skills/magpie-license-compliance-audit/SKILL.md -test -f .claude/skills/magpie-flaky-test-triage/SKILL.md +test -f .agents/skills/magpie-ci-runner-audit/SKILL.md +test -f .agents/skills/magpie-workflow-security-audit/SKILL.md +test -f .agents/skills/magpie-dependency-audit/SKILL.md +test -f .agents/skills/magpie-license-compliance-audit/SKILL.md +test -f .agents/skills/magpie-flaky-test-triage/SKILL.md uv run --project tools/skill-and-tool-validator --group dev skill-and-tool-validate ``` diff --git a/tools/spec-loop/specs/reviewer-routing.md b/tools/spec-loop/specs/reviewer-routing.md index 07c40786..cff48ac4 100644 --- a/tools/spec-loop/specs/reviewer-routing.md +++ b/tools/spec-loop/specs/reviewer-routing.md @@ -14,7 +14,7 @@ source: > but no skill turns that roster plus repository signal into an assignee suggestion. triage-mode.md § What it does ("propose routing to the right human") names the behaviour. Skill ships experimental in - .claude/skills/magpie-reviewer-routing/ with an eval suite under + .agents/skills/magpie-reviewer-routing/ with an eval suite under tools/skill-evals/evals/reviewer-routing/. acceptance: - The skill is read-only on tracker state and proposes-then-confirms; @@ -46,7 +46,7 @@ side: a grounded brief a human acts on, not a state change. ## Where it lives -- Skill: `reviewer-routing` under `.claude/skills/magpie-reviewer-routing/`, +- Skill: `reviewer-routing` under `.agents/skills/magpie-reviewer-routing/`, in the Agentic Triage family alongside `pr-management-triage` and `issue-triage`. Eval suite under `tools/skill-evals/evals/reviewer-routing/`. - Roster source: the project's configured reviewer roster diff --git a/tools/spec-loop/specs/skill-reconciler.md b/tools/spec-loop/specs/skill-reconciler.md index 1d2a4367..e94a209d 100644 --- a/tools/spec-loop/specs/skill-reconciler.md +++ b/tools/spec-loop/specs/skill-reconciler.md @@ -15,7 +15,7 @@ source: > safety baseline that must stay eventually-consistent across every copy. meta-and-quality-tooling.md (the skill-authoring/quality family this joins). Skill ships experimental in - .claude/skills/magpie-skill-reconciler/ with an eval suite under + .agents/skills/magpie-skill-reconciler/ with an eval suite under tools/skill-evals/evals/skill-reconciler/. acceptance: - The reconciler is read-only: it produces a structured diff and a @@ -54,7 +54,7 @@ that a maintainer should not ignore. ## Where it lives -- Skill: `skill-reconciler` at `.claude/skills/magpie-skill-reconciler/`, +- Skill: `skill-reconciler` at `.agents/skills/magpie-skill-reconciler/`, in the meta / quality family with `write-skill`, `optimize-skill`, and `list-skills` (see [meta-and-quality-tooling.md](meta-and-quality-tooling.md)). Eval suite under `tools/skill-evals/evals/skill-reconciler/`. From 4e12be9cb29f9268033660cc64ca23669182e738 Mon Sep 17 00:00:00 2001 From: Tester Date: Sun, 12 Jul 2026 22:38:25 +0200 Subject: [PATCH 2/2] build(prek): add symlink-lint --archive to catch RC-breaking archive symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.1.0-rc1/rc2 uploads were -1'd / rejected because the source archive carried symlink *chains*: the .claude/.github/.kiro relay dirs point at .agents/skills/*, which are themselves symlinks. ATR's upload validator refuses a symlink whose target is itself a symlink ("target outside extraction directory"), so the whole upload failed. Add a third symlink-lint rule (`--archive`) that builds the source archive exactly as the release does (git archive --worktree-attributes of the staged tree, honouring .gitattributes export-ignore) and rejects any symlink a safe extractor refuses: - chain — target is itself a symlink (the ATR rejection) - dangling — target got export-ignored, orphaning the link Wire it as the `symlink-lint-archive` prek hook, fired whenever .gitattributes, an agent-view skills/ tree, skills/, or projects/_template/ changes — the inputs that shape the archive — so a regression fails the commit before an RC is ever cut. release-verify-rc still re-checks the same property against the unpacked tarball. --- .pre-commit-config.yaml | 17 +++ tools/symlink-lint/README.md | 47 ++++++-- .../symlink-lint/src/symlink_lint/__init__.py | 111 +++++++++++++++++- tools/symlink-lint/tests/test_symlink_lint.py | 69 +++++++++++ 4 files changed, 230 insertions(+), 14 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c347e588..afd7f14f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -216,6 +216,23 @@ repos: entry: python3 tools/symlink-lint/src/symlink_lint/__init__.py types: [symlink] pass_filenames: false + # Release-archive guard: build the source archive exactly as the release + # does (git archive of the export tree, honouring .gitattributes + # export-ignore) and reject any symlink a safe extractor refuses — a + # dangling link (its target got export-ignored) or a chain (target is + # itself a symlink, which ATR's upload validator rejects as "outside the + # extraction directory"). This is what -1'd an RC upload; the hook fails + # the commit before an RC is cut. Re-fires whenever .gitattributes, any + # symlink, or the skills/agent-view tree that the archive ships changes. + - repo: local + hooks: + - id: symlink-lint-archive + name: symlink-lint (release archive) + language: system + entry: python3 tools/symlink-lint/src/symlink_lint/__init__.py --archive + files: ^(\.gitattributes|\.(agents|claude|github|kiro)/skills/.*|skills/.*|projects/_template/.*)$ + types_or: [symlink, file] + pass_filenames: false # Workspace-membership guard. The single source of truth for # which Python projects get pre-commit hooks + the CI pytest # matrix is the `[tool.uv.workspace] members` array in the root diff --git a/tools/symlink-lint/README.md b/tools/symlink-lint/README.md index ca8f7025..bc07fe05 100644 --- a/tools/symlink-lint/README.md +++ b/tools/symlink-lint/README.md @@ -48,9 +48,32 @@ every other agent dir (`.claude/`, `.github/`, `.windsurf/`, `.goose/`, canonical, not straight at source). Catches relays that bypass `.agents/` — acyclic, so rule 1 alone would miss them. -**Dangling / unresolvable links are skipped**, never flagged: an adopter's -canonical links legitimately dangle until the gitignored `.apache-magpie/` -snapshot is installed. Broken-target detection is `setup verify`'s job. +**Dangling / unresolvable links are skipped** by rules 1–2, never flagged: +an adopter's canonical links legitimately dangle until the gitignored +`.apache-magpie/` snapshot is installed. Broken-target detection is +`setup verify`'s job. + +3. **Release archive is extractor-safe** (run with `--archive`). Builds the + source archive exactly as the release does — `git archive + --worktree-attributes` of the *staged* tree (`git write-tree`), honouring + `.gitattributes` `export-ignore` — then rejects any symlink in it that a + safe extractor refuses: + - **chain** — the link's target is itself a symlink. A validator that + will not follow a symlink-to-symlink (ASF's [ATR](https://release-test.apache.org/) + upload validator does exactly this) reads the target as escaping the + extraction directory and rejects the whole upload. This is the shape + that `-1`'d an RC: the `.claude` / `.github` / `.kiro` relay dirs chain + through `.agents/`, so they are `export-ignore`d and only the single-hop + `.agents/skills/*` view (which points straight at real `skills/*`) + ships. + - **dangling** — the link's target is absent from the archive (its real + file got `export-ignore`d), orphaning the link. + + Unlike rules 1–2 this inspects the archive, not the working tree, so a + dangling link here *is* a defect — it means the RC would ship a broken + link. Runs as the `symlink-lint-archive` hook (below); `release-verify-rc` + re-checks the same property against the unpacked tarball before the + `[VOTE]`. ## Prerequisites @@ -62,7 +85,8 @@ snapshot is installed. Broken-target detection is `setup verify`'s job. ## How to use ```bash -python3 tools/symlink-lint/src/symlink_lint/__init__.py +python3 tools/symlink-lint/src/symlink_lint/__init__.py # rules 1–2 (working tree) +python3 tools/symlink-lint/src/symlink_lint/__init__.py --archive # rule 3 (release archive) # or, once the workspace is synced: uv run --project tools/symlink-lint symlink-lint ``` @@ -71,10 +95,17 @@ Exit `0` if clean; `1` otherwise, with each offender printed to stderr. ## Wiring -Runs as the `symlink-lint` [prek](https://github.com/j178/prek) hook -(`.pre-commit-config.yaml`), fired on any staged symlink and always on -`prek run --all-files` (CI). Behaviour is locked by the pytest suite under -[`tests/`](tests/), run by the workspace `pytest` hook + CI matrix. +Two [prek](https://github.com/j178/prek) hooks (`.pre-commit-config.yaml`): + +- `symlink-lint` — rules 1–2, fired on any staged symlink and always on + `prek run --all-files` (CI). +- `symlink-lint-archive` — rule 3 (`--archive`), fired whenever + `.gitattributes`, any agent-view `skills/` tree, `skills/`, or + `projects/_template/` changes (the inputs that shape the release archive), + and always on `prek run --all-files`. + +Behaviour is locked by the pytest suite under [`tests/`](tests/), run by the +workspace `pytest` hook + CI matrix. ## Tests diff --git a/tools/symlink-lint/src/symlink_lint/__init__.py b/tools/symlink-lint/src/symlink_lint/__init__.py index 3a4e7c5d..3939595e 100644 --- a/tools/symlink-lint/src/symlink_lint/__init__.py +++ b/tools/symlink-lint/src/symlink_lint/__init__.py @@ -15,7 +15,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -"""Lint the framework's self-adoption skill symlinks. Two rules: +"""Lint the framework's self-adoption skill symlinks. Two working-tree rules: 1. **No cycles** — a symlink must not resolve to its own directory or an ancestor (that traps recursive `**/SKILL.md` scanners in looped paths). @@ -23,11 +23,23 @@ (canonical) points into `../../skills/`; the same link under any other agent dir relays through `../../.agents/skills/magpie-`. -Dangling links are skipped. Full rationale + examples: `README.md`. +Plus one **release-archive** rule, run with `--archive`: -Run as the `symlink-lint` prek hook, or directly: -`python3 tools/symlink-lint/src/symlink_lint/__init__.py`. Exit 0 if clean, -1 otherwise (offenders on stderr). +3. **Archive is extractor-safe** — build the source archive exactly as the + release does (`git archive --worktree-attributes` of the staged tree, + honouring `.gitattributes` `export-ignore`), then reject any symlink in + it that is *dangling* (target absent — orphaned by an `export-ignore`) + or a *chain* (target is itself a symlink — a safe extractor such as + ATR's upload validator rejects it as "target outside extraction + directory"). This is what breaks an RC upload; catching it here fails + the commit before an RC is ever cut. See `README.md`. + +Dangling links are skipped by rules 1-2. Full rationale + examples: +`README.md`. + +Run as the `symlink-lint` / `symlink-lint-archive` prek hooks, or directly: +`python3 tools/symlink-lint/src/symlink_lint/__init__.py [--archive]`. +Exit 0 if clean, 1 otherwise (offenders on stderr). """ from __future__ import annotations @@ -35,6 +47,8 @@ import os import subprocess import sys +import tarfile +import tempfile from pathlib import Path # Directories pruned from the scan (VCS metadata, virtualenvs, vendored @@ -122,6 +136,68 @@ def find_misdirected_relays( return sorted(problems) +def find_archive_symlink_problems(root: Path) -> list[tuple[str, str, str]]: + """Build the source archive the way the release does and return + `(archive_path, target, kind)` for every symlink in it that a safe + extractor rejects. + + The archive is `git archive --worktree-attributes` of the *staged* tree + (`git write-tree`), so it reflects the change being committed and honours + the working-tree `.gitattributes` `export-ignore` rules — identical to + `release-build.md`/`release-rc-cut`. Two rejected kinds: + + - `dangling` — the link's target is not present in the archive (its real + file was `export-ignore`d, orphaning the link). + - `chain` — the link's target is itself a symlink; an extractor that + refuses to follow a symlink-to-symlink reads the target as escaping the + extraction directory (ATR's upload validator rejects this exact shape). + + Returns an empty list if `git` is unavailable or the tree cannot be + written (nothing to assert against).""" + try: + tree = subprocess.run( + ["git", "write-tree"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return [] + if tree.returncode != 0 or not tree.stdout.strip(): + return [] + archived = subprocess.run( + ["git", "archive", "--worktree-attributes", "--format=tar", tree.stdout.strip()], + cwd=root, + capture_output=True, + check=False, + ) + if archived.returncode != 0: + return [] + + problems: list[tuple[str, str, str]] = [] + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) / "archive.tar" + base.write_bytes(archived.stdout) + with tarfile.open(base) as tar: + members = tar.getmembers() + # Map every archived path to its member; symlink targets are resolved + # against this set, never the host filesystem, so the check reflects + # only what the archive itself contains. + by_path = {m.name: m for m in members} + for member in members: + if not member.issym(): + continue + link_dir = os.path.dirname(member.name) + target = os.path.normpath(os.path.join(link_dir, member.linkname)) + resolved = by_path.get(target) + if resolved is None: + problems.append((member.name, member.linkname, "dangling")) + elif resolved.issym(): + problems.append((member.name, member.linkname, "chain")) + return sorted(problems) + + def _rel(link: Path, root: Path) -> Path: try: return link.relative_to(root) @@ -129,8 +205,31 @@ def _rel(link: Path, root: Path) -> Path: return link -def main() -> int: +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + archive_mode = "--archive" in args root = repo_root() + + if archive_mode: + archive = find_archive_symlink_problems(root) + if not archive: + return 0 + out = sys.stderr.write + out("error: release-archive symlink(s) a safe extractor rejects:\n") + for arc_path, arc_target, kind in archive: + hint = ( + "target export-ignored -> dangling in archive" + if kind == "dangling" + else "target is itself a symlink -> extractor sees it outside the archive" + ) + out(f" {arc_path} -> {arc_target} ({kind}: {hint})\n") + out( + "\nThe source archive (git archive of the export tree) must contain only\n" + "single-hop symlinks to real files. Fix .gitattributes export-ignore\n" + "rules or the offending links. See tools/symlink-lint/README.md.\n" + ) + return 1 + cycles = find_cyclic_symlinks(root) relays = find_misdirected_relays(root) if not cycles and not relays: diff --git a/tools/symlink-lint/tests/test_symlink_lint.py b/tools/symlink-lint/tests/test_symlink_lint.py index 2818fc0e..668a94b5 100644 --- a/tools/symlink-lint/tests/test_symlink_lint.py +++ b/tools/symlink-lint/tests/test_symlink_lint.py @@ -27,6 +27,7 @@ from __future__ import annotations import os +import subprocess from collections.abc import Iterable from pathlib import Path from typing import Any @@ -162,3 +163,71 @@ def test_main_returns_one_on_misdirected_relay(tmp_path: Path, monkeypatch: pyte _wire_skill(tmp_path, relay_target="../../skills/x") monkeypatch.setattr(symlink_lint, "repo_root", lambda: tmp_path) assert symlink_lint.main() == 1 + + +# ---- rule 3: release-archive symlink safety ------------------------------- + + +def _git_repo(root: Path, gitattributes: str = "") -> None: + """Init a git repo at ``root``, write ``.gitattributes``, and stage every + file so `git write-tree` / `git archive` have a tree to work on.""" + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "config", "user.email", "t@example.com"], cwd=root, check=True) + subprocess.run(["git", "config", "user.name", "t"], cwd=root, check=True) + if gitattributes: + (root / ".gitattributes").write_text(gitattributes) + subprocess.run(["git", "add", "-A"], cwd=root, check=True) + + +def test_archive_single_hop_links_clean(tmp_path: Path) -> None: + # .agents/skills/magpie-x -> ../../skills/x (a real dir): the only view + # shipped, and it resolves to a real file — extractor-safe. + (tmp_path / "skills" / "x").mkdir(parents=True) + (tmp_path / "skills" / "x" / "SKILL.md").write_text("x\n") + _symlink(tmp_path, ".agents/skills/magpie-x", "../../skills/x") + _git_repo(tmp_path) + assert symlink_lint.find_archive_symlink_problems(tmp_path) == [] + + +def test_archive_relay_chain_flagged(tmp_path: Path) -> None: + # A .claude relay chaining through .agents (target is itself a symlink) + # ships in the archive -> a safe extractor rejects it. This is the exact + # shape that -1'd the RC upload. + _wire_skill(tmp_path, relay_target="../../.agents/skills/magpie-x") + _git_repo(tmp_path) + problems = symlink_lint.find_archive_symlink_problems(tmp_path) + assert [(p, kind) for p, _t, kind in problems] == [(".claude/skills/magpie-x", "chain")] + + +def test_archive_relay_chain_export_ignored_is_clean(tmp_path: Path) -> None: + # The fix: export-ignore the relay dir so only the single-hop .agents view + # ships. The chain never reaches the archive. + _wire_skill(tmp_path, relay_target="../../.agents/skills/magpie-x") + _git_repo(tmp_path, gitattributes=".claude/skills/ export-ignore\n") + assert symlink_lint.find_archive_symlink_problems(tmp_path) == [] + + +def test_archive_dangling_link_flagged(tmp_path: Path) -> None: + # export-ignore the real skills/ dir but keep the .agents view: the link's + # target is gone from the archive -> dangling. + (tmp_path / "skills" / "x").mkdir(parents=True) + (tmp_path / "skills" / "x" / "SKILL.md").write_text("x\n") + _symlink(tmp_path, ".agents/skills/magpie-x", "../../skills/x") + # Root-anchored so only the real skills/ dir drops, not .agents/skills/. + _git_repo(tmp_path, gitattributes="/skills/ export-ignore\n") + problems = symlink_lint.find_archive_symlink_problems(tmp_path) + assert [(p, kind) for p, _t, kind in problems] == [(".agents/skills/magpie-x", "dangling")] + + +def test_main_archive_flag_returns_one_on_chain(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _wire_skill(tmp_path, relay_target="../../.agents/skills/magpie-x") + _git_repo(tmp_path) + monkeypatch.setattr(symlink_lint, "repo_root", lambda: tmp_path) + assert symlink_lint.main(["--archive"]) == 1 + + +def test_main_archive_flag_returns_zero_when_clean(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _wire_skill(tmp_path, relay_target="../../.agents/skills/magpie-x") + _git_repo(tmp_path, gitattributes=".claude/skills/ export-ignore\n") + monkeypatch.setattr(symlink_lint, "repo_root", lambda: tmp_path) + assert symlink_lint.main(["--archive"]) == 0