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