You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/07/12 21:03:03 UTC
(magpie) branch main updated: fix(release): exclude relay symlink chains from source archive + guard it in prek (#845)
This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git
The following commit(s) were added to refs/heads/main by this push:
new 38923326 fix(release): exclude relay symlink chains from source archive + guard it in prek (#845)
38923326 is described below
commit 389233262f323e1e6b25ae718c36cc54c996e146
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sun Jul 12 23:02:58 2026 +0200
fix(release): exclude relay symlink chains from source archive + guard it in prek (#845)
* 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.
* build(prek): add symlink-lint --archive to catch RC-breaking archive symlinks
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.
---------
Co-authored-by: Tester <t...@example.com>
---
.gitattributes | 41 +++++---
.pre-commit-config.yaml | 17 ++++
projects/_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 +-
tools/symlink-lint/README.md | 47 +++++++--
tools/symlink-lint/src/symlink_lint/__init__.py | 111 +++++++++++++++++++--
tools/symlink-lint/tests/test_symlink_lint.py | 69 +++++++++++++
13 files changed, 290 insertions(+), 61 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/.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/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/`.
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-<skill>`.
-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 @@ from __future__ import annotations
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 @@ relays point at ../../.agents/skills/magpie-<skill>.
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