diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8004983 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/validate-repo-maintenance.yml b/.github/workflows/validate-repo-maintenance.yml new file mode 100644 index 0000000..fe6dc91 --- /dev/null +++ b/.github/workflows/validate-repo-maintenance.yml @@ -0,0 +1,28 @@ +name: Validate Repo Maintenance + +# Branch protection should require the Actions check context `validate`. +# GitHub exposes the job check run by this job name, not by the workflow title. + +on: + pull_request: + push: + branches: + - main + +jobs: + validate: + name: validate + runs-on: macos-26 + steps: + # This is a validated floor, not a ceiling; update to newer stable official versions when validated. + - uses: actions/checkout@v6.0.2 + with: + persist-credentials: false + - name: Report selected Xcode + run: xcode-select --print-path + - name: Report Swift toolchain + run: xcrun swift --version + - name: Install Swift repo-maintenance tools + run: brew install swiftformat swiftlint + - name: Run repo-maintenance validation + run: bash scripts/repo-maintenance/validate-all.sh diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..62ecf88 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,92 @@ +# Exported from Gale's SwiftFormat for Xcode settings and curated for repo use. +# This file is the repository source of truth for formatting. If the host app +# configuration changes later, re-export from the shared SwiftFormat settings +# and review the diff before importing it back into the app. + +--rules andOperator,anyObjectProtocol,applicationMain,assertionFailures,blankLineAfterImports,blankLinesAfterGuardStatements,blankLinesAroundMark,blankLinesAtEndOfScope,blankLinesAtStartOfScope,blankLinesBetweenChainedFunctions,blankLinesBetweenImports,blankLinesBetweenScopes,braces,conditionalAssignment,consecutiveBlankLines,consecutiveSpaces,consistentSwitchCaseSpacing,docComments,docCommentsBeforeModifiers,duplicateImports,elseOnSameLine,emptyBraces,emptyExtensions,enumNamespaces,environmentEntry,extensionAccessControl,fileMacro,genericExtensions,headerFileName,hoistAwait,hoistPatternLet,hoistTry,indent,initCoderUnavailable,isEmpty,leadingDelimiters,linebreakAtEndOfFile,linebreaks,modifierOrder,noForceTryInTests,noForceUnwrapInTests,noGuardInTests,numberFormatting,opaqueGenericParameters,organizeDeclarations,preferFinalClasses,privateStateVariables,redundantAsync,redundantBackticks,redundantBreak,redundantClosure,redundantEquatable,redundantExtensionACL,redundantFileprivate,redundantGet,redundantInit,redundantInternal,redundantLet,redundantLetError,redundantMemberwiseInit,redundantNilInit,redundantObjc,redundantOptionalBinding,redundantParens,redundantPattern,redundantPublic,redundantRawValues,redundantReturn,redundantSelf,redundantSendable,redundantStaticSelf,redundantSwiftTestingSuite,redundantThrows,redundantType,redundantTypedThrows,redundantVariable,redundantViewBuilder,semicolons,simplifyGenericConstraints,sortDeclarations,sortImports,sortTypealiases,spaceAroundBraces,spaceAroundBrackets,spaceAroundComments,spaceAroundGenerics,spaceAroundOperators,spaceAroundParens,spaceInsideBrackets,spaceInsideComments,spaceInsideGenerics,spaceInsideParens,strongOutlets,strongifiedSelf,swiftTestingTestCaseNames,todos,trailingClosures,trailingCommas,trailingSpace,typeSugar,validateTestCases,void,wrap,wrapArguments,wrapAttributes,wrapLoopBodies,wrapMultilineFunctionChains,wrapSingleLineComments,yodaConditions + +--acronyms ID,URL,UUID +--allow-partial-wrapping true +--anonymous-for-each convert +--asset-literals visual-width +--binary-grouping 4,8 +--line-between-guards false +--category-mark "MARK: %c" +--class-threshold 0 +--closing-paren balanced +--closure-void remove +--complex-attributes preserve +--computed-var-attributes preserve +--conditional-assignment after-property +--date-format system +--decimal-grouping 3,6 +--doc-comments before-declarations +--else-position same-line +--empty-braces no-space +--enum-namespaces always +--equatable-macro none +--exponent-case lowercase +--extension-acl on-extension +--file-macro "#file" +--func-attributes preserve +--group-blank-lines true +--guard-else auto +--header ignore +--hex-grouping 4,8 +--hex-literal-case uppercase +--ifdef outdent +--import-grouping alpha,access-control +--indent 4 +--indent-case true +--indent-strings false +--inferred-types always +--init-coder-nil false +--line-after-marks true +--linebreaks lf +--mark-categories false +--mark-class-threshold 40 +--mark-enum-threshold 40 +--mark-extension-threshold 40 +--mark-struct-threshold 40 +--max-width none +--operator-func spaced +--organization-mode type +--organize-types actor,class,enum,struct +--pattern-let hoist +--prefer-synthesized-init-for-internal-structs never +--property-types infer-locals-only +--ranges no-space +--redundant-async tests-only +--redundant-throws tests-only +--self remove +--semicolons inline-only +--short-optionals preserve-struct-inits +--smart-tabs enabled +--some-any true +--sort-swiftui-properties alphabetize +--stored-var-attributes preserve +--struct-threshold 40 +--strip-unused-args always +--suite-name-format standard-identifiers +--test-case-name-format raw-identifiers +--timezone system +--trailing-commas always +--trim-whitespace always +--type-attributes preserve +--type-blank-lines remove +--type-body-marks preserve +--type-delimiter space-after +--class-threshold 40 +--enum-threshold 40 +--extension-threshold 40 +--void-type Void +--wrap-arguments preserve +--wrap-collections preserve +--wrap-conditions preserve +--wrap-effects preserve +--wrap-return-type preserve +--wrap-string-interpolation default +--wrap-ternary default +--wrap-type-aliases preserve +--xcode-indentation disabled +--yoda-swap always diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..715bb5c --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,20 @@ +# Keep SwiftLint focused on non-formatting checks. +# SwiftFormat owns visual shape in this repository. + +excluded: + - .build + - .local + +only_rules: + - duplicate_imports + - empty_count + - fatal_error_message + - force_try + - force_unwrapping + - unused_import + +force_try: + severity: warning + +force_unwrapping: + severity: warning diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md new file mode 100644 index 0000000..bde7863 --- /dev/null +++ b/ACCESSIBILITY.md @@ -0,0 +1,137 @@ +# Accessibility + +This document defines the accessibility expectations for the repository's command-line output and Markdown documentation; it does not claim conformance for the Apple software being researched. + +## Table of Contents + +- [Overview](#overview) +- [Standards Baseline](#standards-baseline) +- [Accessibility Architecture](#accessibility-architecture) +- [Engineering Workflow](#engineering-workflow) +- [Known Gaps](#known-gaps) +- [User Support and Reporting](#user-support-and-reporting) +- [Verification and Evidence](#verification-and-evidence) + +## Overview + +### Status + +The repository has a documented accessibility baseline but no formal conformance audit. + +### Scope + +This contract covers project-owned command-line output, Markdown structure, and any future user-facing surface. It does not cover or certify macOS, private frameworks, target applications, or raw third-party runtime output. + +### Accessibility Goals + +Keep research usable without relying on color, pointer input, animation, or visual-only structure, and preserve user control over TCC-gated and potentially mutating experiments. + +## Standards Baseline + +### Target Standard + +Documentation and future web or graphical surfaces should target applicable WCAG 2.2 Level AA criteria. Command-line tools use an internal baseline of plain-text readability, stable ordering, descriptive diagnostics, and keyboard-only operation. + +### Conformance Language Rules + +Do not make unqualified compliance, certification, or conformance claims without a scoped audit and recorded evidence. State targets, tested surfaces, gaps, and dates precisely. + +### Supported Platforms and Surfaces + +The current project-owned surfaces are macOS command-line tools and GitHub-flavored Markdown. Research involving the macOS Accessibility API is an experimental input surface, not proof of this repository's conformance. + +## Accessibility Architecture + +### Semantic Structure + +Use ordered headings, descriptive link text, fenced commands, lists for real sets, and tables only when relationships benefit from them. Do not encode meaning through indentation or typography alone. + +### Input and Keyboard Model + +Current tools are invoked from the keyboard and must not require pointer input. Future interactive tools must document shortcuts and preserve standard platform input behavior. + +### Focus Management + +There is no project-owned graphical focus model today. Any future UI must provide predictable focus order, visible focus, and recovery after sheets, dialogs, or asynchronous updates. + +### Naming and Announcements + +CLI labels, warnings, errors, and state changes must be descriptive and identify the affected target or operation. Future UI controls and dynamic status must expose meaningful accessible names and announcements. + +### Color, Contrast, and Motion + +CLI and documentation meaning must not depend on color. Any future UI must provide sufficient contrast, support Reduce Motion, and avoid unnecessary flashing or animation. + +### Zoom, Reflow, and Responsive Behavior + +Keep CLI output readable as plain text without fixed-width layout assumptions beyond code blocks. Documentation should remain understandable under browser zoom and narrow layouts. + +### Media, Captions, and Alternatives + +The repository currently ships no project-owned audio or video documentation. Any future media used to explain research must include an equivalent transcript or captioned alternative. + +## Engineering Workflow + +### Design and Implementation Rules + +Prefer semantic Markdown and plain text, descriptive actions and errors, deterministic output, and explicit consent before TCC prompts or state mutation. Keep research probes non-interactive unless interaction is essential and documented. + +### Automated Testing + +Swift Testing covers reusable helper behavior, but there is no dedicated automated accessibility test suite for the current CLI and Markdown surfaces. + +### Manual Testing + +For accessibility-relevant changes, inspect heading order, link meaning, non-color communication, terminal readability, and whether the documented command can be completed from the keyboard. + +### Assistive Technology Coverage + +No recurring VoiceOver or other assistive-technology test matrix is currently recorded. Add scoped evidence before claiming support for a specific assistive technology. + +### Definition of Done + +An accessibility-relevant change is ready when the affected surface follows this baseline, its manual checks are recorded, new gaps are listed here or in the roadmap, and no unsupported conformance claim is introduced. + +## Known Gaps + +### Current Exceptions + +- No formal WCAG audit has been completed. +- No recurring VoiceOver verification matrix exists. +- Raw framework, daemon, and unified-log output may contain inaccessible formatting outside project control. + +### Planned Remediation + +Track concrete, issue-sized accessibility work in [`ROADMAP.md`](./ROADMAP.md) when a project-owned surface needs remediation. Add a dedicated test matrix if a graphical or interactive product surface is introduced. + +### Ownership + +Maintainers changing a project-owned user-facing surface are responsible for updating this document and recording the evidence for their change. + +## User Support and Reporting + +### Feedback Path + +Use a GitHub issue in this repository and identify the command, document, OS version, assistive technology when relevant, expected behavior, and observed barrier. Do not include personal notifications, messages, account data, or sensitive raw captures in a public issue. + +### Triage Expectations + +Treat loss of access, destructive behavior, or blocked keyboard operation as high priority. Preserve privacy by excluding personal notifications, messages, account data, and raw captures from issue reports. + +## Verification and Evidence + +### CI Signals + +```sh +scripts/repo-maintenance/validate-all.sh +``` + +This checks repository structure and Swift behavior. It is supporting engineering evidence, not a conformance audit. + +### Audit Cadence + +Review accessibility whenever a project-owned user-facing surface changes and before making a stronger accessibility claim. No calendar-based audit cadence is currently established. + +### Review History + +- 2026-07-17: Established the initial CLI and Markdown accessibility baseline; no formal conformance audit was performed. diff --git a/AGENTS.md b/AGENTS.md index 9ca7939..4b466cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,56 +1,104 @@ # AGENTS.md -## Project Scope +Use this file for durable repo-local guidance before changing research, documentation, Swift tooling, or maintainer workflow surfaces in this repository. -This is Gale's private Apple-platform spelunking repository for SIP-disabled, local-only research, educational notes, and prototype Swift tooling. +## Repository Scope -Default to source-of-truth-first work: inspect the local framework, SDK, binary, headers, symbols, runtime behavior, or official documentation before summarizing. Keep public-release, App Store, marketplace, customer-facing, or redistributed use out of scope unless Gale explicitly opens that path. +### What This File Covers -## Research Workflow +This is Gale's public Apple-platform spelunking repository for SIP-disabled, local-only experiments, educational notes, and prototype Swift tooling. Cleaned research knowledge is intentionally public; supported product releases, App Store or marketplace distribution, customer-facing deployment, and redistribution of Apple-owned or third-party material remain out of scope unless Gale explicitly opens that path. + +### Where To Look First + +- Read [`README.md`](./README.md) for the current repository shape and [`ROADMAP.md`](./ROADMAP.md) for planned work. +- Read the target's named directory under `docs/frameworks/` before inspecting its matching raw captures under `research/`. +- Treat `Package.swift` as the source of truth for Swift products and targets. +- Read [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the human research and review workflow. + +## Working Rules + +### Change Scope - Work on one framework, service, daemon, or tightly related category at a time. -- Keep raw captures, generated headers, command transcripts, and experimental notes under `research//`. -- Promote cleaned, stable documentation into `docs/frameworks//`. -- Separate verified observations from inference. Label anything inferred from strings, symbols, generated headers, or behavior as inference until a runtime or documentation source confirms it. -- For OS comparisons, record both the active OS version and the SDK version or Xcode path used for evidence. -- Prefer repeatable commands and small helper tools over one-off manual notes when the result will matter again. -- Do not commit secrets, private tokens, personal account data, or unrelated machine-local state. +- Keep raw captures, generated headers, command transcripts, and experimental notes in the target's named directory under `research/`. +- Promote cleaned, stable documentation into the matching named directory under `docs/frameworks/`. +- Preserve existing document structure and checklists unless the requested work includes a documentation normalization pass. +- Surface any move from public research documentation into supported tool distribution or a materially broader research target before implementing it. -## Apple Documentation Gate +### Source of Truth -- For Apple, Swift, SwiftPM, and Xcode work, use the relevant Apple Dev Skill before implementation or architectural advice. -- For official docs lookup, use `explore-apple-swift-docs`: Xcode MCP `DocumentationSearch` first, Dash.app MCP second, Dash HTTP only when needed, then checked-out source, generated DocC, GitHub/source repositories, release notes, or readable official web docs. +- Inspect the local framework, SDK, binary, headers, symbols, runtime behavior, or official documentation before summarizing. +- For Apple documentation, use the applicable Apple Dev Skill: Xcode `DocumentationSearch` first, Dash second, then checked-out source, generated DocC, canonical source repositories, release notes, or readable official web docs. - Do not treat generic web snippets, metadata shells, or bare Apple Developer URLs as proof that documentation was read. -- If no official or local documentation is available for a private API, say that plainly and continue from local evidence. +- Separate verified observations from inference. Label conclusions inferred from strings, symbols, generated headers, or behavior until runtime or documentation evidence confirms them. +- For OS comparisons, record both the active OS version and the SDK version or Xcode path used for evidence. + +### Communication and Escalation + +- Explain an evidence gap plainly when a private API has no official or local documentation, then continue from local evidence. +- Ask before widening the target, mutating media or account state, launching visible apps, running simulators, or performing disruptive service checks. +- Make architecture pivots and public-facing implications explicit before implementation. + +## Commands + +### Setup + +```sh +swift build +``` + +The package has no required secrets, environment files, or external package dependencies. Individual probes may still depend on the host OS, TCC grants, private-framework availability, or SIP state documented by their target writeups. + +### Validation + +Run the repo-owned validation entrypoint: + +```sh +scripts/repo-maintenance/validate-all.sh +``` + +For a Swift change, this must include serialized `swift build` and `swift test` checks. For a documentation-only change, also run a Markdown inventory or link sanity check when practical. + +### Optional Project Commands + +```sh +scripts/repo-maintenance/sync-shared.sh +scripts/repo-maintenance/release.sh --help +``` + +Use `sync-shared.sh` only for explicit repo-owned shared-sync steps. Use `release.sh` only when Gale explicitly requests release or publish choreography; this research repo does not currently publish versioned releases. + +## Review and Delivery + +### Review Expectations + +- Keep evidence, interpretation, and open questions visibly distinct. +- Confirm commands, paths, OS versions, SDK versions, symbols, and failure modes against current evidence. +- Keep repository-facing links portable and relative. +- Update nearby documentation and tests when a tool, target, or verified conclusion changes. + +### Definition of Done -## Swift Package Workflow +Work is complete when the requested slice is coherent, raw evidence is stored under the correct target, stable findings are promoted into the target docs, relevant validation passes, and remaining uncertainty is recorded without overclaiming. -- Treat `Package.swift` as the source of truth for Swift tools in this repo. -- Use `bootstrap-swift-package` only when creating a fresh Swift package from scratch. -- Use `sync-swift-package-guidance` when this repo's SwiftPM guidance needs to be refreshed or merged forward. -- Use `swift-package-build-run-workflow` for manifest, dependency, plugin, resource, build, and run work. -- Use `swift-package-testing-workflow` for Swift Testing, XCTest holdouts, fixtures, and package test diagnosis. -- Use `swift build` and `swift test` as default validation after package-level changes. -- Prefer Swift Testing for new tests. -- Keep Swift code in Swift 6 language mode. -- Choose project-owned Swift names with the `SPK` prefix unless a narrower target-specific prefix is introduced and documented. +## Safety Boundaries -## Private Framework Handling +### Never Do -- Keep private-framework experiments local-first and clearly labeled. -- Do not imply a private API is safe for public distribution, App Store submission, or a public package unless Gale explicitly asks for that separate analysis. -- When linking private frameworks, loading symbols dynamically, using generated headers, or relying on SIP-disabled behavior, document the exact boundary and observed failure mode. -- Prefer read-only experiments before mutating media state, playback state, routes, account state, or system services. +- Do not commit secrets, private tokens, personal account data, unrelated machine-local state, or ignored bulk captures. +- Redact home-directory usernames, email addresses, phone numbers, device names, personal signing identities, certificate hashes, and developer-team identifiers from captures and documentation before committing them. +- Do not imply a private API is safe for public distribution, App Store submission, or a public package without a separate explicit analysis. +- Do not perform mutating framework experiments before a read-only boundary is understood and documented. +- Do not run GUI validation, simulators, visible apps, or disruptive local service checks without approval. +- Do not hard-code `DEVELOPER_DIR`, DerivedData, build-products, or artifact paths. -## Documentation Standards +### Ask Before -- Preserve existing document structure and checklists unless Gale asks to reorganize them. -- Use portable relative links in repository docs. -- Each framework writeup should include: scope, environment, evidence inventory, type and symbol notes, interesting APIs, hooks or notifications, permissions and entitlements, experiments, open questions, and references. -- Keep command examples reproducible and explicit about the working directory, SDK, target OS, and toolchain when those details affect output. +- Mutating media state, playback state, routes, account state, system services, or user data. +- Linking private frameworks into a new distributable surface or changing the boundary between public knowledge and redistributable artifacts. +- Adding a new queue, subsystem, storage model, dependency, or ownership boundary. +- Starting release, tag, merge, or publication choreography. -## Validation +## Local Overrides -- For docs-only changes, run a quick file inventory or Markdown sanity check when practical. -- For Swift changes, run `swift build` and `swift test` unless the change is explicitly documentation-only or the toolchain is blocked. -- Do not run GUI validation, simulators, visible apps, or disruptive local service checks without Gale's approval. +There are no more-specific `AGENTS.md` files in this repository currently. If one is added later, its closer guidance refines this root file for work in that subtree. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2e5fd6c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# Contributing to Spelunking + +Use this guide to keep public Apple-platform research understandable, reproducible, and safe to revisit. + +## Table of Contents + +- [Overview](#overview) +- [Contribution Workflow](#contribution-workflow) +- [Local Setup](#local-setup) +- [Development Expectations](#development-expectations) +- [Pull Request Expectations](#pull-request-expectations) +- [Communication](#communication) +- [License and Contribution Terms](#license-and-contribution-terms) + +## Overview + +### Who This Guide Is For + +This guide serves contributors and agents preparing documentation, evidence captures, Swift probes, or maintainer-tooling changes in this public repository. + +### Before You Start + +Read [`AGENTS.md`](./AGENTS.md), the relevant target index under `docs/frameworks/`, and the target's raw-evidence README under `research/`. Check [`ROADMAP.md`](./ROADMAP.md) and open GitHub issues before starting work that may overlap another branch. + +## Contribution Workflow + +### Choosing Work + +Choose one framework, service, daemon, or tightly related category. Define the evidence question and whether the experiment is read-only before collecting data. Use a focused feature or research branch and keep unrelated targets separate. + +### Making Changes + +1. Record the host OS and relevant SDK or Xcode version. +2. Store repeatable commands, raw captures, and generated interfaces under the target's named directory in `research/`. +3. Put reusable Swift code under `Sources/` with Swift Testing coverage under `Tests/` where practical. +4. Promote only cleaned, stable conclusions into the target's matching named directory under `docs/frameworks/`. +5. Label inference explicitly and link conclusions to the evidence that supports them. + +Do not commit ignored bulk captures, personal account data, secrets, or unrelated machine state. Before committing a capture, redact home-directory usernames, email addresses, phone numbers, device names, personal signing identities, certificate hashes, and developer-team identifiers while preserving the technical result. + +### Asking For Review + +A change is ready when the diff is scoped, commands are reproducible, environment details are present, claims match the evidence, links are portable, and the relevant validation passes. Call out private API, entitlement, TCC, sandbox, SIP, XPC, daemon, and side-effect boundaries in the review summary. + +## Local Setup + +### Runtime Config + +There are no required environment files, secrets, external packages, or local services. Use the selected Xcode command-line toolchain without hard-coded `DEVELOPER_DIR` or build-output paths. + +Some target-specific experiments require TCC permission, private-framework availability, a specific OS build, or SIP-disabled conditions. Document those requirements in the target writeup; never encode personal machine paths or credentials into the repo. + +### Runtime Behavior + +Build and test the package first: + +```sh +swift build +swift test +swift run spelunk +``` + +Start with a read-only probe. A successful build does not prove that a private runtime call is available or authorized, so record the observed process output and failure mode separately. + +## Development Expectations + +### Naming Conventions + +- Prefix project-owned Swift types with `SPK` unless a narrower target prefix is documented. +- Use matching named directories under `docs/frameworks/` and `research/` with the target's canonical Apple name. +- Name captures with enough OS, SDK, date, or experiment context to distinguish their environment. +- Keep shared support code in dedicated types or extensions rather than hiding it inside an unrelated probe entrypoint. + +### Accessibility Expectations + +Follow [`ACCESSIBILITY.md`](./ACCESSIBILITY.md). The repository currently ships command-line tools and Markdown, not a graphical product, so relevant obligations are readable output, semantic document structure, non-color-only meaning, and preserving user control over TCC-gated or mutating experiments. + +When researching Apple's Accessibility API, distinguish the API being observed from claims about this repository's own accessibility conformance. + +### Verification + +Run the repo-owned validation entrypoint, which includes the package build and tests: + +```sh +scripts/repo-maintenance/validate-all.sh +``` + +For documentation-only work, also inspect the changed Markdown structure and links. Do not run visible apps, simulators, GUI automation, or disruptive service checks without approval. + +## Pull Request Expectations + +Summarize the target, evidence gathered, conclusions promoted, commands run, environment used, and any remaining inference or blocked runtime proof. Keep reviewable raw captures separate from generated or ignored bulk output. + +## Communication + +Surface uncertain interpretation, risky mutation, public-facing implications, and scope expansion before they become part of the implementation. If a new queue, subsystem, storage model, dependency, or ownership boundary becomes necessary, stop and make that architecture decision explicit. + +## License and Contribution Terms + +A formal reuse license has not been selected yet. Contributions should advance the public research record without adding Apple-owned code, personal data, secrets, or third-party material that the repository cannot lawfully redistribute. diff --git a/README.md b/README.md index 946f9a9..7f32584 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,85 @@ # Spelunking -Private Apple platform research for local, user-consented accessibility and SIP-disabled "peer behind the curtain" exploration. +Public Apple-platform framework and service research, durable evidence, and prototype Swift tooling. -This repository is for focused investigations into macOS, iOS, and Apple-platform frameworks, services, daemons, private APIs, headers, symbols, and runtime behavior. Each pass should study one framework, service, subsystem, or tightly related category at a time, then leave behind documentation that is useful for future tools and projects. +## Table of Contents -The first research target is `MediaRemote.framework` on macOS 26.5 and the installed macOS 27 beta SDK, with related now-playing, media-control, route, and userland access surfaces in scope. +- [Overview](#overview) +- [Quick Start](#quick-start) +- [Usage](#usage) +- [Development](#development) +- [Repo Structure](#repo-structure) +- [Release Notes](#release-notes) +- [License](#license) -## Repo Layout +## Overview -- `docs/`: durable writeups meant to be read later. -- `docs/frameworks//`: polished notes for one framework, service, or subsystem. -- `research//`: raw evidence, command notes, symbol dumps, generated headers, experiments, and local-only working material for one research target. -- `Sources/`: Swift package sources for reusable research helpers and command-line tools. -- `Tests/`: Swift package tests for reusable helpers. -- `tools/`: standalone scripts and helper notes that are not SwiftPM targets yet. +### Status -## Current Tools +Active public research repository. Findings are environment-specific, and experimental tools are not packaged as supported public products. -- `spelunk`: prints the current seeded research target paths. -- `mr-now-playing-probe`: read-only dynamic `MediaRemote.framework` probe for global now-playing info, app PID/is-playing/client state, client/player lists, and short notification observation windows. -- `mr-internal-probe`: Objective-C helper for internal `MediaRemote.framework` wrapper experiments that need direct Objective-C runtime calls. -- `mr-interface-probe`: Objective-C runtime interface describer for targeted `MediaRemote.framework` classes, methods, properties, ivars, and protocols. -- `mr-route-probe`: read-oriented endpoint and output-device probe for route boundary experiments. -- `MRXPCTraceInterpose`: private dynamic interposer for tracing MediaRemote XPC dictionary sends from local probe processes. -- `now-playing-fixture`: metadata-only fixture for testing whether `MPNowPlayingInfoCenter` publication appears through MediaRemote. -- `tools/mediaremote-inventory.zsh`: repeatable local capture script for dyld-cache exports, imports, strings, ObjC names, SDK diffs, support binaries, resources, and entitlements. -- `tools/mediaremote-entitlement-experiment.zsh`: repeatable local runner that builds `mr-internal-probe`, signs copied variants with candidate private entitlements, and captures runtime differences. -- `tools/mediaremote-daemon-observe.zsh`: repeatable local runner that executes a probe and captures focused `mediaremoted`/unified-log evidence for the same time window. -- `tools/mediaremote-interface-capture.zsh`: repeatable local runner that captures selected Objective-C runtime interfaces from the loaded framework into ignored research output. -- `tools/mediaremote-message-surfaces.zsh`: repeatable extractor for XPC keys, message logs, request handlers, protobuf/message symbols, and transport helpers from an inventory capture. -- `tools/mediaremote-message-id-callsites.zsh`: repeatable disassembly parser for immediate MediaRemote XPC message type call sites. -- `tools/mediaremote-xpc-trace-observe.zsh`: repeatable DYLD interposer runner that pairs XPC message-ID traces with focused daemon logs. +### What This Project Is -## Research Shape +This repository is for focused investigations into macOS, iOS, and Apple-platform frameworks, services, daemons, private APIs, headers, symbols, and runtime behavior. Each pass studies one framework, service, subsystem, or tightly related category at a time. -Every target should answer the same core questions: +### Motivation -- What public, private, and runtime-discovered entry points exist? -- Which types, functions, notifications, constants, callbacks, and XPC or daemon edges look useful? -- Which symbols are present in the active OS framework, and which appear in the installed beta SDK? -- What can userland call directly, what needs entitlements or TCC, and what only works under SIP-disabled/private-lane conditions? -- Which observations were verified locally, and which are still inferred from headers, symbols, strings, or behavior? +Research should leave behind evidence and documentation that can support future local tools and projects. Raw captures remain separate from cleaned conclusions so later work can distinguish verified observations from inference. -Keep raw captures in `research//` and promote only cleaned, reusable knowledge into `docs/frameworks//`. +## Quick Start -## Current Targets +The package requires macOS 26 or later and Swift 6.2 or later. Build the research tools and run the non-mutating target index: -`MediaRemote.framework` and related media-control surfaces: - -- now-playing metadata and queues -- playback state and command dispatch -- origin, route, and destination discovery -- app, daemon, XPC, notification, and private-framework boundaries -- macOS 26.5 versus macOS 27 beta SDK differences - -See `docs/frameworks/MediaRemote/README.md` for the starting outline. +```sh +swift build +swift test +swift run spelunk +``` -`UserNotifications` and `NotificationCenter.app` accessibility research: +Individual probes may require a particular macOS build, private frameworks, TCC permission, or SIP-disabled conditions. Read the relevant framework writeup before running one. -- Notification Center's supported, app-scoped UserNotifications API boundary -- read-only Accessibility inspection of the system Notification Center UI -- observer capability evidence for the active macOS release -- preview-privacy and TCC limitations +## Usage -Run the read-only probe with: +The safest starting points are the read-only commands: ```sh swift run spelunk notifications --max-depth 6 +swift run mr-now-playing-probe --all +swift run mr-now-playing-probe --observe 10 --application +swift run mr-interface-probe +swift run mr-route-probe ``` -It requests no interaction with the notification UI and performs no accessibility actions. The host process must already have Accessibility permission. See `docs/frameworks/UserNotifications/README.md` for the research boundary and `research/UserNotifications/README.md` for the evidence plan. +The package also contains `mr-internal-probe`, `now-playing-fixture`, and the `MRXPCTraceInterpose` dynamic library for narrower experiments. Repeatable MediaRemote capture helpers live under [`tools/`](./tools/README.md). -Useful current commands: +For each target, use: -```sh -swift run mr-now-playing-probe --all -swift run mr-now-playing-probe --origins -swift run mr-internal-probe -swift run mr-interface-probe -swift run mr-route-probe -swift run mr-now-playing-probe --observe 10 --application -tools/mediaremote-inventory.zsh -tools/mediaremote-entitlement-experiment.zsh -tools/mediaremote-daemon-observe.zsh -tools/mediaremote-interface-capture.zsh -tools/mediaremote-message-surfaces.zsh -tools/mediaremote-message-id-callsites.zsh -tools/mediaremote-xpc-trace-observe.zsh +- Named target directories under [`docs/frameworks/`](./docs/README.md) for cleaned, durable findings. +- Named target directories under `research/` for raw evidence, generated interfaces, command transcripts, and experiment notes. +- `Sources/` and `Tests/` for reusable Swift helpers and probes. + +Every writeup should identify the active OS and SDK or Xcode version, distinguish verified behavior from inference, and document permissions, entitlements, sandbox, SIP, XPC, notification, or daemon boundaries that affect the result. + +## Development + +For research intake, local setup, validation, documentation boundaries, and review expectations, see [`CONTRIBUTING.md`](./CONTRIBUTING.md). Durable agent-facing rules live in [`AGENTS.md`](./AGENTS.md), and planned work lives in [`ROADMAP.md`](./ROADMAP.md). + +## Repo Structure + +```text +. +├── Sources/ Swift libraries, executables, and probe targets +├── Tests/ Swift Testing coverage for reusable helpers +├── docs/frameworks/ Cleaned framework and subsystem writeups +├── research/ Raw captures and target-specific evidence +├── scripts/repo-maintenance Local validation, sync, and release entrypoints +└── tools/ Standalone evidence-capture helpers ``` + +## Release Notes + +This research workspace does not currently publish versioned releases. Notable planning changes are recorded in [`ROADMAP.md`](./ROADMAP.md), while research findings remain attached to their target writeups and Git history. + +## License + +A formal reuse license has not been selected yet. The repository is public because research knowledge should be available to learn from, verify, and extend; public visibility does not grant rights to redistribute Apple-owned code, private data, or third-party material captured during research. diff --git a/ROADMAP.md b/ROADMAP.md index 46a99e6..cbc2811 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,29 +1,101 @@ -# Roadmap +# Project Roadmap -## Phase 0: Repository Foundation +This roadmap tracks durable research milestones without turning raw investigation notes into a second planning system. + +## Table of Contents + +- [Vision](#vision) +- [Product Principles](#product-principles) +- [Milestone Progress](#milestone-progress) +- [Milestone 0: Repository Foundation](#milestone-0-repository-foundation) +- [Milestone 1: MediaRemote Baseline](#milestone-1-mediaremote-baseline) +- [Milestone 2: Media Control Experiments](#milestone-2-media-control-experiments) +- [Milestone 3: Reusable Research Tooling](#milestone-3-reusable-research-tooling) +- [Small Tickets](#small-tickets) +- [Backlog Candidates](#backlog-candidates) +- [History](#history) + +## Vision + +Build a trustworthy public knowledge base and set of local tools for understanding Apple-platform frameworks and services from source evidence through safe runtime experiments. + +## Product Principles + +- Study one coherent target at a time. +- Keep raw evidence separate from cleaned documentation. +- Prefer read-only, repeatable experiments before mutation. +- Record environment and permission boundaries with every claim they affect. +- Keep research knowledge public while treating supported releases and redistribution of Apple-owned or third-party material as separate explicit decisions. + +## Milestone Progress + +- Milestone 0: Repository Foundation - Completed +- Milestone 1: MediaRemote Baseline - In Progress +- Milestone 2: Media Control Experiments - Planned +- Milestone 3: Reusable Research Tooling - In Progress + +## Milestone 0: Repository Foundation + +### Status + +Completed + +### Scope + +- [x] Establish a SwiftPM-ready public research repository with durable documentation, evidence directories, agent guidance, and local maintenance entrypoints. + +### Tickets - [x] Create a SwiftPM-ready research repository. -- [x] Add local agent guidance for Apple docs, private research evidence, and Swift tooling. +- [x] Add local agent guidance for Apple docs, private-framework research evidence, and Swift tooling. - [x] Establish durable documentation and raw research directories. - [x] Seed the first target outline for `MediaRemote.framework`. +- [x] Install repo-owned validation, shared-sync, and release-maintenance entrypoints. + +### Exit Criteria + +- [x] A new target has clear locations for source, tests, raw evidence, polished docs, and repeatable maintenance commands. + +## Milestone 1: MediaRemote Baseline + +### Status + +In Progress + +### Scope + +- [ ] Map the active MediaRemote framework and beta SDK surfaces, prove safe userland observations, and document the permission and process boundaries around now-playing, routes, XPC, and daemon behavior. -## Phase 1: MediaRemote Baseline +### Tickets - [x] Locate active macOS 26.5 framework paths and installed macOS 27 beta SDK framework paths. -- [x] Record framework metadata: install names, architectures, linked libraries, entitlements, strings, exported symbols, Objective-C classes, selectors, and notifications. +- [x] Record framework metadata, linked libraries, entitlements, strings, exports, Objective-C names, selectors, and notifications. - [x] Compare macOS 26.5 and macOS 27 beta SDK symbol surfaces. - [x] Query the live dyld shared-cache export surface for `MediaRemote.framework`. -- [x] Identify local wrapper examples, XPC services, launchd-adjacent jobs, daemons, and related frameworks. -- [ ] Generate or recover private headers/interfaces for high-value symbols. -- [x] Document userland-callable APIs for now-playing metadata, playback commands, queue information, origin discovery, and route or destination behavior. -- [x] Mark calls that require entitlements, elevated privileges, SIP-disabled conditions, or private-framework linking. -- [x] Build one small Swift helper that safely probes now-playing state without mutating playback. -- [x] Test the read-only helper against active Spotify playback. -- [x] Build a metadata-only now-playing fixture and test whether it appears through MediaRemote. +- [x] Identify local wrappers, XPC services, jobs, daemons, and related frameworks. +- [ ] Generate or recover private headers and interfaces for high-value symbols. +- [x] Document userland-callable now-playing, command, queue, origin, route, and destination APIs. +- [x] Mark entitlement, privilege, SIP, and private-framework boundaries. +- [x] Build and test a non-mutating now-playing probe against active Spotify playback. +- [x] Build a metadata-only now-playing fixture and test its MediaRemote visibility. - [x] Resolve active Spotify identity through origin and player-path APIs. -- [ ] Confirm non-empty now-playing dictionary shape through origin/player-path APIs, daemon-facing inspection, or an app-bundle fixture. +- [ ] Confirm a non-empty now-playing dictionary through origin/player paths, daemon observation, or an app-bundle fixture. -## Phase 2: Media Control Experiments +### Exit Criteria + +- [ ] High-value interfaces are documented and a non-empty now-playing path is reproduced with environment evidence. + +## Milestone 2: Media Control Experiments + +### Status + +Planned + +### Scope + +- [ ] Extend the baseline into narrowly bounded media-state and command experiments after the read-only behavior and permission model are understood. + +### Tickets - [ ] Build targeted experiments for read-only now-playing state. - [ ] Build targeted experiments for playback command dispatch. @@ -31,15 +103,48 @@ - [ ] Explore per-app, per-origin, and route-aware media state. - [ ] Document failure modes, required host context, sandbox behavior, and privacy prompts. -## Phase 3: Reusable Research Tooling +### Exit Criteria + +- [ ] Each experiment has a reproducible command, captured result, documented side effects, and explicit permission boundary. + +## Milestone 3: Reusable Research Tooling + +### Status + +In Progress + +### Scope + +- [ ] Turn repeated evidence-gathering work into small composable Swift helpers and scripts that future research targets can reuse. + +### Tickets - [ ] Add shared Swift helpers for command execution, symbol inventory parsing, plist parsing, and evidence capture. -- [ ] Add repeatable framework inventory scripts for future targets. -- [ ] Add diff tooling for active OS versus SDK framework surfaces. +- [x] Add repeatable MediaRemote inventory and observation scripts. +- [ ] Generalize framework inventory scripts for future targets. +- [ ] Add reusable diff tooling for active OS versus SDK surfaces. - [ ] Add report templates for frameworks, daemons, XPC services, and subsystem categories. -## Later Targets +### Exit Criteria + +- [ ] A second framework target can reuse the core capture and reporting path without copying MediaRemote-specific implementation. + +## Small Tickets + +- [x] Add a Markdown link sanity check to repo-maintenance validation. +- [ ] Normalize the existing Swift sources against the checked-in SwiftFormat profile; the initial lint audit found formatting drift while SwiftLint remained clean. +- [ ] Select and document an explicit reuse license for the repository's original code and research documentation. + +## Backlog Candidates + +- [ ] Continue related media-framework, daemon, and service targets discovered during the MediaRemote pass. +- [ ] Expand Messages and Phone research from the existing ownership and surface maps. +- [ ] Expand UserNotifications and Notification Center accessibility research from the current read-only baseline. +- [ ] Continue `WallpaperAgent` and debug XPC research on its dedicated branch. +- [ ] Explore other macOS and iOS private frameworks that can inform local educational tools. + +## History -- [ ] Related media frameworks, daemons, and services discovered during the `MediaRemote.framework` pass. -- [ ] Other macOS and iOS private frameworks that can inform Gale's educational projects and local tools. -- [ ] SIP-disabled local-only workflows that should never be promoted into public package, App Store, or customer-facing surfaces without a separate decision. +- 2026-07-15: Created the repository foundation and seeded the MediaRemote baseline. +- 2026-07-16: Added Messages, Phone, UserNotifications, Notification Center, and WallpaperAgent research slices. +- 2026-07-17: Normalized repository documentation and installed the SwiftPM repo-maintenance profile. diff --git a/Sources/MRInternalProbe/main.m b/Sources/MRInternalProbe/main.m index e575690..c212fe7 100644 --- a/Sources/MRInternalProbe/main.m +++ b/Sources/MRInternalProbe/main.m @@ -425,7 +425,7 @@ int main(void) { return 1; } - dispatch_queue_t queue = dispatch_queue_create("com.galewilliams.spelunking.mediaremote.internal-probe", DISPATCH_QUEUE_SERIAL); + dispatch_queue_t queue = dispatch_queue_create("org.gaelicghost.spelunking.mediaremote.internal-probe", DISPATCH_QUEUE_SERIAL); dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); __block id activeOrigin = nil; diff --git a/Sources/MRNowPlayingProbe/SPKMRNowPlayingProbe.swift b/Sources/MRNowPlayingProbe/SPKMRNowPlayingProbe.swift index 2727fcb..dcab8a0 100644 --- a/Sources/MRNowPlayingProbe/SPKMRNowPlayingProbe.swift +++ b/Sources/MRNowPlayingProbe/SPKMRNowPlayingProbe.swift @@ -155,7 +155,7 @@ struct SPKMRNowPlayingProbe { } let getNowPlayingInfo = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingInfo.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.now-playing-probe") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.now-playing-probe") let semaphore = DispatchSemaphore(value: 0) let timeoutSeconds = 5 @@ -188,7 +188,7 @@ struct SPKMRNowPlayingProbe { } private static func primeNowPlayingNotifications(handle: UnsafeMutableRawPointer) throws { - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.now-playing-prime") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.now-playing-prime") if let symbol = dlsym(handle, "MRMediaRemoteRegisterForNowPlayingNotifications") { let register = unsafeBitCast(symbol, to: MRMediaRemoteRegisterForNowPlayingNotifications.self) @@ -233,7 +233,7 @@ struct SPKMRNowPlayingProbe { } let function = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingClients.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.\(symbolName)") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.\(symbolName)") let semaphore = DispatchSemaphore(value: 0) print("MediaRemote read-only now-playing clients probe") @@ -279,7 +279,7 @@ struct SPKMRNowPlayingProbe { } let function = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingPlayer.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.\(symbolName)") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.\(symbolName)") let semaphore = DispatchSemaphore(value: 0) print("MediaRemote read-only now-playing player probe") @@ -318,7 +318,7 @@ struct SPKMRNowPlayingProbe { } let function = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingInfoForObject.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.\(symbolName)") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.\(symbolName)") let semaphore = DispatchSemaphore(value: 0) var result: NSDictionary? @@ -410,7 +410,7 @@ struct SPKMRNowPlayingProbe { } let function = unsafeBitCast(symbol, to: MRMediaRemoteGetOrigin.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.\(symbolName)") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.\(symbolName)") let semaphore = DispatchSemaphore(value: 0) function(callbackQueue) { success, origin in @@ -448,7 +448,7 @@ struct SPKMRNowPlayingProbe { } let function = unsafeBitCast(symbol, to: MRMediaRemoteGetOrigins.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.\(symbolName)") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.\(symbolName)") let semaphore = DispatchSemaphore(value: 0) function(callbackQueue) { origins in @@ -618,7 +618,7 @@ struct SPKMRNowPlayingProbe { } let function = unsafeBitCast(symbol, to: MRMediaRemoteGetObjectsForOrigin.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.\(symbolName)") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.\(symbolName)") let semaphore = DispatchSemaphore(value: 0) var result: NSArray? @@ -644,7 +644,7 @@ struct SPKMRNowPlayingProbe { } let function = unsafeBitCast(symbol, to: MRMediaRemoteGetObjectForOrigin.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.\(symbolName)") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.\(symbolName)") let semaphore = DispatchSemaphore(value: 0) var result: AnyObject? @@ -708,7 +708,7 @@ struct SPKMRNowPlayingProbe { } let function = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingBool.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.\(symbolName)") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.\(symbolName)") let semaphore = DispatchSemaphore(value: 0) function(callbackQueue) { value in @@ -731,7 +731,7 @@ struct SPKMRNowPlayingProbe { } let function = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingInt32.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.\(symbolName)") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.\(symbolName)") let semaphore = DispatchSemaphore(value: 0) function(callbackQueue) { value in @@ -752,7 +752,7 @@ struct SPKMRNowPlayingProbe { } let function = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingClient.self) - let callbackQueue = DispatchQueue(label: "com.galewilliams.spelunking.mediaremote.\(symbolName)") + let callbackQueue = DispatchQueue(label: "org.gaelicghost.spelunking.mediaremote.\(symbolName)") let semaphore = DispatchSemaphore(value: 0) function(callbackQueue) { client in diff --git a/docs/frameworks/MediaRemote/README.md b/docs/frameworks/MediaRemote/README.md index 6d754c7..d6b6cc5 100644 --- a/docs/frameworks/MediaRemote/README.md +++ b/docs/frameworks/MediaRemote/README.md @@ -15,7 +15,7 @@ The goal is to document types, functions, signatures, hooks, notifications, call | --- | --- | | Active OS | macOS 26.5.2, build 25F84 | | SDK comparison | Xcode 26.6 MacOSX26.5 SDK and Xcode 27.0 beta SDK | -| Primary machine | GMBP16 | +| Primary machine | Apple silicon MacBook Pro | | Swift toolchain at repo bootstrap | Apple Swift 6.3.3 | The first evidence capture is recorded in `../../../research/MediaRemote/baseline-2026-07-15.md`. diff --git a/docs/frameworks/MediaRemote/experiments.md b/docs/frameworks/MediaRemote/experiments.md index 28e0a57..d827207 100644 --- a/docs/frameworks/MediaRemote/experiments.md +++ b/docs/frameworks/MediaRemote/experiments.md @@ -73,15 +73,15 @@ Capture `research/MediaRemote/experiments/entitlements/20260716T083557Z`: - `--identity auto` resolved to a local Apple Development certificate hash. - `codesign` succeeded for the same four private-entitlement variants. -- Signature details show `Authority=Apple Development: Gale Williams (AMRC3N39SQ)` and `TeamIdentifier=BC73766F69`. +- Signature details confirmed a local Apple Development identity and team association; personal certificate and team identifiers are redacted. - Each candidate entitlement embedded successfully. - Each private-entitlement variant still exited with status 137 before probe output. - Unified log evidence reports taskgated `Unsatisfied entitlements: com.apple.mediaremote.now-playing-read-access`, restricted entitlement validation failure, and kernel `load code signature error 4`. Capture `research/MediaRemote/experiments/entitlements/20260716T083725Z`: -- Explicit Developer ID signing with certificate hash `7C250E5B3750CAC924FD0960D224A7BA5E3E4399` succeeded for the same four private-entitlement variants. -- Signature details show `Authority=Developer ID Application: Gale Williams (BC73766F69)` and `TeamIdentifier=BC73766F69`. +- Explicit signing with a local Developer ID Application identity succeeded for the same four private-entitlement variants; the certificate hash is redacted. +- Signature details confirmed a Developer ID Application chain and team association; personal certificate and team identifiers are redacted. - Each candidate entitlement embedded successfully. - Each private-entitlement variant still exited with status 137 before probe output. - Unified log evidence names all four unsatisfied entitlement keys and reports restricted entitlement validation failure plus kernel `load code signature error 4`. diff --git a/docs/frameworks/MediaRemote/permissions-policy.md b/docs/frameworks/MediaRemote/permissions-policy.md index dee5642..f290350 100644 --- a/docs/frameworks/MediaRemote/permissions-policy.md +++ b/docs/frameworks/MediaRemote/permissions-policy.md @@ -151,15 +151,15 @@ Current Apple Development identity result from capture `20260716T083557Z`: - `--identity auto` resolved to a local Apple Development certificate hash. - `codesign` succeeded for all four private-entitlement variants. -- Signature details show an Apple Development chain and team identifier `BC73766F69`. +- Signature details show an Apple Development chain and team association; the personal team identifier is redacted. - Each requested private entitlement is embedded in the corresponding variant. - Every private-entitlement variant still exited with status 137 before running probe code. - Unified log evidence includes taskgated reporting `Unsatisfied entitlements: com.apple.mediaremote.now-playing-read-access`; AMFI still reports restricted-entitlement validation failure and kernel `load code signature error 4`. Current Developer ID identity result from capture `20260716T083725Z`: -- Explicit Developer ID signing with certificate hash `7C250E5B3750CAC924FD0960D224A7BA5E3E4399` succeeded for all four private-entitlement variants. -- Signature details show a Developer ID Application chain and team identifier `BC73766F69`. +- Explicit signing with a local Developer ID Application identity succeeded for all four private-entitlement variants; the certificate hash is redacted. +- Signature details show a Developer ID Application chain and team association; the personal team identifier is redacted. - Each requested private entitlement is embedded in the corresponding variant. - Every private-entitlement variant still exited with status 137 before running probe code. - Unified log evidence names all four unsatisfied entitlement keys: `com.apple.mediaremote.now-playing-read-access`, `com.apple.mediaremote.full-now-playing-read-access`, `com.apple.mediaremote.device-info`, and `com.apple.nowplaying.entitlement`. diff --git a/scripts/repo-maintenance/config/profile.env b/scripts/repo-maintenance/config/profile.env new file mode 100644 index 0000000..9c0a869 --- /dev/null +++ b/scripts/repo-maintenance/config/profile.env @@ -0,0 +1,3 @@ +# Managed by maintain-project-repo. Do not hand-edit unless you also control the installer contract. +REPO_MAINTENANCE_PROFILE="swift-package" +REPO_MAINTENANCE_PROFILE_DESCRIPTION="Swift Package Manager repo-maintenance profile for library, tool, and package repos." diff --git a/scripts/repo-maintenance/config/release.env b/scripts/repo-maintenance/config/release.env new file mode 100644 index 0000000..5cb1e1c --- /dev/null +++ b/scripts/repo-maintenance/config/release.env @@ -0,0 +1,16 @@ +# Repo-maintenance release defaults. +REPO_MAINTENANCE_DEFAULT_RELEASE_MODE=standard +REPO_MAINTENANCE_RELEASE_BRANCH=main +REPO_MAINTENANCE_REMOTE_CI_MODE=full + +# GitHub can accept branch, tag, PR, check, review, and release mutations before +# those surfaces are immediately readable. These defaults keep release scripts +# explicit about intentional waits instead of failing on transient indexing gaps. +REPO_MAINTENANCE_GH_WAIT_TIMEOUT_SECONDS=120 +REPO_MAINTENANCE_GH_WAIT_POLL_SECONDS=5 + +# Keep full local validation as the default release gate. For repositories whose +# GitHub CI is intentionally heavy, use --remote-ci-mode defer so release.sh +# pauses after branch push, PR creation, and initial check discovery. Codex can +# then use a native thread Timer/Wakeup or heartbeat automation to resume later +# instead of leaving a long-running shell process open just to poll GitHub. diff --git a/scripts/repo-maintenance/config/validation.env b/scripts/repo-maintenance/config/validation.env new file mode 100644 index 0000000..c85b147 --- /dev/null +++ b/scripts/repo-maintenance/config/validation.env @@ -0,0 +1,2 @@ +# Repo-maintenance validation defaults. +REPO_MAINTENANCE_REQUIRE_AGENTS=true diff --git a/scripts/repo-maintenance/hooks/pre-commit.sample b/scripts/repo-maintenance/hooks/pre-commit.sample new file mode 100755 index 0000000..8fc8726 --- /dev/null +++ b/scripts/repo-maintenance/hooks/pre-commit.sample @@ -0,0 +1,35 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(git rev-parse --show-toplevel)" +config_file="$repo_root/.swiftformat" +staged_file_list="$(mktemp "${TMPDIR:-/tmp}/swiftformat-staged.XXXXXX")" +trap 'rm -f "$staged_file_list"' EXIT HUP INT TERM + +if ! command -v swiftformat >/dev/null 2>&1; then + echo "SwiftFormat pre-commit hook could not find the \`swiftformat\` CLI on PATH. Install SwiftFormat before committing, or bypass once with --no-verify if you are unblocking an emergency." >&2 + exit 1 +fi + +if [ ! -f "$config_file" ]; then + echo "SwiftFormat pre-commit hook expected a checked-in config at $config_file, but it was missing. Restore the managed .swiftformat file or refresh maintain-project-repo before committing." >&2 + exit 1 +fi + +cd "$repo_root" +git diff --cached --name-only --diff-filter=ACMR -- '*.swift' > "$staged_file_list" + +if [ ! -s "$staged_file_list" ]; then + exit 0 +fi + +echo "Running SwiftFormat on staged Swift sources..." +swiftformat --config "$config_file" --filelist "$staged_file_list" + +while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + git add -- "$relative_path" +done < "$staged_file_list" + +echo "Verifying staged Swift sources with SwiftFormat lint..." +swiftformat --lint --config "$config_file" --filelist "$staged_file_list" diff --git a/scripts/repo-maintenance/lib/common.sh b/scripts/repo-maintenance/lib/common.sh new file mode 100755 index 0000000..145ba00 --- /dev/null +++ b/scripts/repo-maintenance/lib/common.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env sh +set -eu + +COMMON_DIR="${REPO_MAINTENANCE_COMMON_DIR:-}" + +if [ -z "$COMMON_DIR" ]; then + COMMON_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +fi + +REPO_MAINTENANCE_ROOT=$(CDPATH= cd -- "$COMMON_DIR/.." && pwd) +REPO_ROOT=$(CDPATH= cd -- "$REPO_MAINTENANCE_ROOT/../.." && pwd) +REPO_MAINTENANCE_PROFILE="generic" +REPO_MAINTENANCE_PROFILE_DESCRIPTION="Generic repo-maintenance baseline with no Swift or Xcode specialization." + +log() { + printf '%s\n' "$*" +} + +warn() { + printf 'WARN: %s\n' "$*" >&2 +} + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +load_env_file() { + env_file="$1" + [ -f "$env_file" ] || return 0 + set -a + # shellcheck disable=SC1090 + . "$env_file" + set +a +} + +load_profile_env() { + load_env_file "$REPO_MAINTENANCE_ROOT/config/profile.env" +} + +positive_integer_or_default() { + value="$1" + default_value="$2" + + case "$value" in + ''|*[!0-9]*) + printf '%s\n' "$default_value" + ;; + 0) + printf '%s\n' "$default_value" + ;; + *) + printf '%s\n' "$value" + ;; + esac +} + +is_valid_semver_tag() { + tag_name="$1" + prerelease_identifier='(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' + printf '%s\n' "$tag_name" | grep -Eq "^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-${prerelease_identifier}(\.${prerelease_identifier})*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$" +} + +is_semver_prerelease_tag() { + tag_name="$1" + is_valid_semver_tag "$tag_name" || return 1 + tag_without_build_metadata="${tag_name%%+*}" + [ "${tag_without_build_metadata#*-}" != "$tag_without_build_metadata" ] +} + +expected_github_prerelease_value() { + tag_name="$1" + if is_semver_prerelease_tag "$tag_name"; then + printf '%s\n' "true" + else + printf '%s\n' "false" + fi +} + +github_release_create_prerelease_flag() { + tag_name="$1" + if is_semver_prerelease_tag "$tag_name"; then + printf '%s\n' "--prerelease" + fi +} + +verify_github_release_prerelease_metadata() { + tag_name="$1" + expected_value="$(expected_github_prerelease_value "$tag_name")" + + actual_value="$(gh release view "$tag_name" --json isPrerelease --jq .isPrerelease 2>/dev/null || true)" + case "$actual_value" in + true|false) + ;; + *) + die "GitHub release $tag_name exists, but its prerelease metadata was not readable. Confirm gh can read release JSON metadata before rerunning release.sh." + ;; + esac + + [ "$actual_value" = "$expected_value" ] || die "GitHub release $tag_name prerelease metadata mismatch: tag implies isPrerelease=$expected_value but GitHub reports isPrerelease=$actual_value. Update the release metadata or delete and recreate the release before rerunning release.sh." +} + +github_wait_timeout() { + value="$1" + default_timeout="$(positive_integer_or_default "${REPO_MAINTENANCE_GH_WAIT_TIMEOUT_SECONDS:-120}" 120)" + positive_integer_or_default "$value" "$default_timeout" +} + +github_wait_poll_seconds() { + value="$1" + default_poll_seconds="$(positive_integer_or_default "${REPO_MAINTENANCE_GH_WAIT_POLL_SECONDS:-5}" 5)" + positive_integer_or_default "$value" "$default_poll_seconds" +} + +wait_for_remote_branch() { + branch_name="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_REMOTE_BRANCH_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_REMOTE_BRANCH_POLL_SECONDS:-}")" + elapsed_seconds="0" + + log "Waiting up to ${timeout_seconds}s for remote branch origin/$branch_name to become visible." + + while :; do + if git -C "$REPO_ROOT" ls-remote --exit-code --heads origin "$branch_name" >/dev/null 2>&1; then + log "Remote branch origin/$branch_name is visible." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "Remote branch origin/$branch_name was not visible after ${timeout_seconds}s. Confirm the branch push succeeded and that the origin remote is reachable before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +wait_for_remote_tag() { + tag_name="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_REMOTE_TAG_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_REMOTE_TAG_POLL_SECONDS:-}")" + elapsed_seconds="0" + + log "Waiting up to ${timeout_seconds}s for remote tag $tag_name to become visible." + + while :; do + if git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$tag_name" >/dev/null 2>&1; then + log "Remote tag $tag_name is visible." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "Remote tag $tag_name was not visible after ${timeout_seconds}s. Confirm the tag push succeeded and that GitHub has indexed the tag before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +wait_for_github_release() { + tag_name="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_GH_RELEASE_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_GH_RELEASE_POLL_SECONDS:-}")" + elapsed_seconds="0" + + log "Waiting up to ${timeout_seconds}s for GitHub release $tag_name to become readable." + + while :; do + if gh release view "$tag_name" >/dev/null 2>&1; then + log "GitHub release $tag_name is readable." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "GitHub release $tag_name was not readable after ${timeout_seconds}s. Confirm release creation succeeded and GitHub has indexed the release before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +ensure_git_repo() { + git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "maintain-project-repo must run inside a git worktree rooted at $REPO_ROOT." +} + +run_dispatch_dir() { + dir="$1" + label="$2" + ran_any="false" + + for script in "$dir"/*.sh; do + [ -e "$script" ] || continue + ran_any="true" + log "Running $label step $(basename "$script")" + sh "$script" + done + + if [ "$ran_any" = "false" ]; then + log "No $label steps are currently defined under $dir." + fi +} diff --git a/scripts/repo-maintenance/release.sh b/scripts/repo-maintenance/release.sh new file mode 100755 index 0000000..6c61034 --- /dev/null +++ b/scripts/repo-maintenance/release.sh @@ -0,0 +1,563 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" +. "$SELF_DIR/lib/common.sh" + +load_profile_env +load_env_file "$SELF_DIR/config/release.env" + +mode="${REPO_MAINTENANCE_DEFAULT_RELEASE_MODE:-standard}" +release_tag="" +skip_validate="false" +skip_gh_release="false" +skip_version_bump="false" +base_branch="${REPO_MAINTENANCE_RELEASE_BRANCH:-main}" +skip_branch_cleanup="false" +dry_run="false" +remote_ci_mode="${REPO_MAINTENANCE_REMOTE_CI_MODE:-full}" +resume_pr_number="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --mode) + mode="${2:-}" + shift 2 + ;; + --version) + release_tag="${2:-}" + shift 2 + ;; + --skip-validate) + skip_validate="true" + shift + ;; + --skip-gh-release) + skip_gh_release="true" + shift + ;; + --skip-version-bump) + skip_version_bump="true" + shift + ;; + --base-branch) + base_branch="${2:-}" + shift 2 + ;; + --remote-ci-mode) + remote_ci_mode="${2:-}" + shift 2 + ;; + --resume-pr) + resume_pr_number="${2:-}" + shift 2 + ;; + --skip-branch-cleanup) + skip_branch_cleanup="true" + shift + ;; + --dry-run) + dry_run="true" + shift + ;; + -h|--help) + cat <<'USAGE' +Usage: + release.sh --mode standard --version [--base-branch main] [--skip-validate] [--skip-version-bump] [--skip-gh-release] [--remote-ci-mode full|defer] [--skip-branch-cleanup] [--dry-run] + release.sh --mode standard --version --resume-pr [--base-branch main] [--skip-validate] [--skip-gh-release] [--skip-branch-cleanup] [--dry-run] + release.sh --mode submodule --version [--skip-validate] [--skip-gh-release] [--dry-run] +USAGE + exit 0 + ;; + *) + die "Unknown release argument: $1" + ;; + esac +done + +[ -n "$release_tag" ] || die "Pass --version vX.Y.Z when running the release workflow." + +export REPO_MAINTENANCE_RELEASE_MODE="$mode" +export RELEASE_TAG="$release_tag" +export REPO_MAINTENANCE_SKIP_GH_RELEASE="$skip_gh_release" +export REPO_MAINTENANCE_DRY_RUN="$dry_run" +export REPO_MAINTENANCE_REMOTE_CI_MODE="$remote_ci_mode" + +ensure_clean_worktree() { + status_output="$(git -C "$REPO_ROOT" status --porcelain)" + [ -z "$status_output" ] || die "Release workflow requires committed changes and a clean worktree before it can continue." +} + +ensure_gh_cli() { + command -v gh >/dev/null 2>&1 || die "Standard release mode requires the GitHub CLI gh so it can create the pull request, watch CI, inspect review comments, merge, and publish the release." +} + +ensure_semver_tag() { + is_valid_semver_tag "$RELEASE_TAG" || die "Release tag must use strict vX.Y.Z SemVer syntax, with optional prerelease and build metadata identifiers." +} + +ensure_remote_ci_mode() { + case "$REPO_MAINTENANCE_REMOTE_CI_MODE" in + full|defer) + ;; + *) + die "Remote CI mode must be either full or defer. Use full to watch GitHub checks in this script, or defer to pause after initial check discovery and continue from a Codex wakeup." + ;; + esac +} + +current_branch() { + git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD || true +} + +ensure_branch_release_context() { + branch_name="$(current_branch)" + [ -n "$branch_name" ] || die "Standard release mode requires a named feature branch or worktree instead of detached HEAD." + [ "$branch_name" != "$base_branch" ] || die "Standard release mode must run from a release branch or worktree, not protected $base_branch." + printf '%s\n' "$branch_name" +} + +run_version_bump() { + release_version="${RELEASE_TAG#v}" + version_bump_script="$SELF_DIR/version-bump.sh" + bump_subject="release: bump versions for $RELEASE_TAG" + + if [ "$skip_version_bump" = "true" ]; then + log "Skipping repo version bump because --skip-version-bump was requested." + return 0 + fi + + if git -C "$REPO_ROOT" log --format=%s "$base_branch..HEAD" | grep -Fqx "$bump_subject"; then + log "Version bump commit for $RELEASE_TAG already exists on the release branch; continuing without running the bump hook again." + return 0 + fi + + [ -x "$version_bump_script" ] || die "Standard release mode expected an executable repo-specific version bump hook at $version_bump_script. Add that hook so the repo's version surfaces move together, or rerun with --skip-version-bump when this release intentionally has no version-bearing files." + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would run $version_bump_script $release_version with RELEASE_TAG=$RELEASE_TAG." + return 0 + fi + + RELEASE_VERSION="$release_version" "$version_bump_script" "$release_version" + + if [ -z "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + die "Version bump hook completed without changing files. Update $version_bump_script to edit the repo's version surfaces, or rerun with --skip-version-bump if this release intentionally has no version bump." + fi + + git -C "$REPO_ROOT" add -A + git -C "$REPO_ROOT" commit -m "release: bump versions for $RELEASE_TAG" + log "Committed version bump for $RELEASE_TAG." +} + +create_release_tag() { + target_commit="${1:-HEAD}" + target_sha="$(git -C "$REPO_ROOT" rev-parse "$target_commit^{commit}")" + tag_sha="$(git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" + + if [ -n "$tag_sha" ]; then + tag_commit_sha="$(git -C "$REPO_ROOT" rev-list -n 1 "$RELEASE_TAG")" + [ "$tag_commit_sha" = "$target_sha" ] || die "Tag $RELEASE_TAG already exists and does not point at the intended release commit $target_sha." + log "Tag $RELEASE_TAG already points at intended release commit $target_sha." + return 0 + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would create annotated tag $RELEASE_TAG at release commit $target_sha." + return 0 + fi + + git -C "$REPO_ROOT" tag -a "$RELEASE_TAG" "$target_sha" -m "Release $RELEASE_TAG" + log "Created annotated tag $RELEASE_TAG at release commit $target_sha." +} + +push_release_branch() { + branch_name="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would push branch $branch_name to origin." + return 0 + fi + + git -C "$REPO_ROOT" push -u origin "$branch_name" + log "Pushed branch $branch_name." + wait_for_remote_branch "$branch_name" +} + +push_release_tag() { + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would push tag $RELEASE_TAG to origin." + return 0 + fi + + git -C "$REPO_ROOT" push origin "$RELEASE_TAG" + log "Pushed tag $RELEASE_TAG." + wait_for_remote_tag "$RELEASE_TAG" +} + +create_or_update_pr() { + branch_name="$1" + PR_NUMBER="" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would create or update a release PR from $branch_name into $base_branch." + PR_NUMBER="DRY-RUN" + return 0 + fi + + body_file="$(mktemp "${TMPDIR:-/tmp}/repo-maintenance-release-pr.XXXXXX")" + trap 'rm -f "$body_file"' EXIT INT TERM + + cat >"$body_file" </dev/null + log "Updated existing release PR #$pr_number at $pr_url." + else + gh pr create --base "$base_branch" --head "$branch_name" --title "release: prepare $RELEASE_TAG" --body-file "$body_file" >/dev/null + pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" + [ -n "$pr_number" ] || die "GitHub CLI did not return a release PR number after creating the pull request." + pr_url="$(gh pr view "$pr_number" --json url --jq '.url')" + log "Created release PR #$pr_number at $pr_url." + PR_NUMBER="$pr_number" + return 0 + fi + + PR_NUMBER="$pr_number" +} + +watch_ci() { + pr_number="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would watch CI for PR #$pr_number." + return 0 + fi + + log "Watching CI for PR #$pr_number." + if ! gh pr checks "$pr_number" --watch; then + die "CI is not green for PR #$pr_number. Fix the failing checks, push the branch, and rerun release.sh so it can watch CI again." + fi + log "CI is green for PR #$pr_number." +} + +defer_remote_ci_if_requested() { + pr_number="$1" + branch_name="$2" + + [ "$REPO_MAINTENANCE_REMOTE_CI_MODE" = "defer" ] || return 1 + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would defer remote CI after PR #$pr_number reports initial checks." + return 0 + fi + + pr_url="$(gh pr view "$pr_number" --json url --jq '.url')" + log "Remote CI mode is defer, so release.sh is pausing after local validation, branch push, PR creation, and initial check discovery." + log "Release is not complete yet. Let GitHub finish CI for PR #$pr_number, then continue from branch $branch_name with:" + log " bash scripts/repo-maintenance/release.sh --mode standard --version $RELEASE_TAG" + log "Codex should create a same-thread heartbeat automation for this wait when available, then resume by checking $pr_url and rerunning the command above instead of leaving a shell script open to poll GitHub." + log "The heartbeat should also wait for review-bot status contexts such as CodeRabbit to finish before merging; pending review contexts are not a clean merge signal." + return 0 +} + +wait_for_initial_pr_checks() { + pr_number="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would wait for GitHub to report initial checks on PR #$pr_number." + return 0 + fi + + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_INITIAL_CHECK_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_INITIAL_CHECK_POLL_SECONDS:-}")" + elapsed_seconds="0" + last_state="no check data returned yet" + + log "Waiting up to ${timeout_seconds}s for GitHub to report initial checks on PR #$pr_number." + + while :; do + last_state="$(gh pr checks "$pr_number" --json name,state,workflow --jq 'map(.name + ":" + .state) | join(", ")' 2>/dev/null || printf 'no checks reported')" + check_count="$(gh pr checks "$pr_number" --json name,state,workflow --jq 'length' 2>/dev/null || printf '0')" + case "$check_count" in + ''|*[!0-9]*) + check_count="0" + ;; + esac + + if [ "$check_count" -gt 0 ]; then + log "Found $check_count initial check(s) for PR #$pr_number." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "No checks were reported for PR #$pr_number after ${timeout_seconds}s. Last observed state: $last_state. Confirm the GitHub Actions workflow triggers for the release branch, Actions is enabled, and the branch push succeeded before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +unresolved_review_thread_count() { + pr_number="$1" + repo_name_with_owner="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" + repo_owner="${repo_name_with_owner%/*}" + repo_name="${repo_name_with_owner#*/}" + + unresolved_count="$(gh api graphql \ + -f owner="$repo_owner" \ + -f name="$repo_name" \ + -F number="$pr_number" \ + -f query='query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + pageInfo { hasNextPage } + nodes { isResolved } + } + } + } + }' \ + --jq 'if .data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage then "MORE_THAN_100" else ([.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length | tostring) end')" + + [ "$unresolved_count" != "MORE_THAN_100" ] || die "PR #$pr_number has more than 100 review threads. Inspect and resolve the full review thread set manually before rerunning release.sh." + case "$unresolved_count" in + ''|*[!0-9]*) + die "GitHub returned an unreadable unresolved review-thread count for PR #$pr_number: $unresolved_count. Confirm gh can read pull-request review threads before rerunning release.sh." + ;; + esac + + printf '%s\n' "$unresolved_count" +} + +wait_for_pr_review_state() { + pr_number="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_PR_REVIEW_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_PR_REVIEW_POLL_SECONDS:-}")" + elapsed_seconds="0" + last_state="PR review/comment state has not been read yet" + + log "Waiting up to ${timeout_seconds}s for GitHub review/comment state on PR #$pr_number." + + while :; do + last_state="$(gh pr view "$pr_number" --json reviewDecision,comments,reviews --jq '"reviewDecision=" + (.reviewDecision // "") + ", comments=" + ((.comments | length) | tostring) + ", reviews=" + ((.reviews | length) | tostring)' 2>/dev/null || printf 'GitHub did not return PR review/comment state')" + case "$last_state" in + "GitHub did not return PR review/comment state") + ;; + *) + log "GitHub review/comment state is readable for PR #$pr_number: $last_state." + return 0 + ;; + esac + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "GitHub review/comment state for PR #$pr_number was not readable after ${timeout_seconds}s. Last observed state: $last_state. Confirm the PR exists and GitHub is returning review data before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +check_pr_comments() { + pr_number="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would check PR #$pr_number for comments and requested changes." + return 0 + fi + + wait_for_pr_review_state "$pr_number" + + review_decision="$(gh pr view "$pr_number" --json reviewDecision --jq '.reviewDecision // ""')" + unresolved_thread_count="$(unresolved_review_thread_count "$pr_number")" + + if [ "$review_decision" = "CHANGES_REQUESTED" ]; then + gh pr view "$pr_number" --comments + die "PR #$pr_number has requested changes. Address valid concerns in code, or add out-of-scope concerns to ROADMAP.md, resolve the threads, push, and rerun release.sh." + fi + + if [ "$unresolved_thread_count" != "0" ]; then + gh pr view "$pr_number" --comments + die "PR #$pr_number has $unresolved_thread_count unresolved review thread(s). Address valid concerns, add intentionally deferred work to ROADMAP.md, resolve the threads, and rerun release.sh." + fi + + log "PR #$pr_number has no blocking review state." +} + +merge_pr() { + pr_number="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would merge PR #$pr_number into $base_branch with a merge commit and delete the remote branch." + MERGED_COMMIT_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD)" + return 0 + fi + + gh pr merge "$pr_number" --merge --delete-branch + MERGED_COMMIT_SHA="$(gh pr view "$pr_number" --json mergeCommit --jq '.mergeCommit.oid // empty')" + [ -n "$MERGED_COMMIT_SHA" ] || die "PR #$pr_number merged, but GitHub did not return its merge commit. Rerun with --resume-pr $pr_number after GitHub reports the merge commit." + log "Merged PR #$pr_number into $base_branch." +} + +fetch_merged_base_commit() { + merged_commit_sha="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would fetch origin/$base_branch and verify release commit $merged_commit_sha is reachable from it." + return 0 + fi + + git -C "$REPO_ROOT" fetch origin "$base_branch" + git -C "$REPO_ROOT" merge-base --is-ancestor "$merged_commit_sha" "origin/$base_branch" || die "Release commit $merged_commit_sha is not reachable from origin/$base_branch. Confirm PR merge state before tagging or rerun with the correct --resume-pr number." + log "Verified release commit $merged_commit_sha is reachable from origin/$base_branch." +} + +create_github_release() { + if [ "$REPO_MAINTENANCE_SKIP_GH_RELEASE" = "true" ]; then + log "Skipping GitHub release creation because --skip-gh-release was requested." + return 0 + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" + log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag${prerelease_flag:+ $prerelease_flag}." + return 0 + fi + + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + verify_github_release_prerelease_metadata "$RELEASE_TAG" + log "GitHub release $RELEASE_TAG already exists." + return 0 + fi + + prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" + # shellcheck disable=SC2086 + gh release create "$RELEASE_TAG" --verify-tag --generate-notes $prerelease_flag + log "Created GitHub release $RELEASE_TAG." + wait_for_github_release "$RELEASE_TAG" + verify_github_release_prerelease_metadata "$RELEASE_TAG" +} + +cleanup_merged_branches() { + release_branch_name="$1" + + if [ "$skip_branch_cleanup" = "true" ]; then + log "Skipping local merged-branch cleanup because --skip-branch-cleanup was requested." + return 0 + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would prune origin and delete the merged local release branch $release_branch_name when safe." + return 0 + fi + + git -C "$REPO_ROOT" remote prune origin + git -C "$REPO_ROOT" branch -d "$release_branch_name" >/dev/null 2>&1 || warn "Could not delete merged local release branch $release_branch_name; it may be checked out in another worktree." + log "Cleaned up merged local release branch $release_branch_name where safe." +} + +resume_merged_release() { + case "$resume_pr_number" in + ''|*[!0-9]*) + die "--resume-pr requires a numeric merged pull-request number." + ;; + esac + + ensure_clean_worktree + if [ "$skip_validate" != "true" ]; then + sh "$SELF_DIR/validate-all.sh" + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + merged_commit_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" + release_branch_name="$(current_branch)" + log "Would verify PR #$resume_pr_number is merged into $base_branch and read its exact merge commit." + else + pr_state="$(gh pr view "$resume_pr_number" --json state --jq '.state')" + pr_base_branch="$(gh pr view "$resume_pr_number" --json baseRefName --jq '.baseRefName')" + release_branch_name="$(gh pr view "$resume_pr_number" --json headRefName --jq '.headRefName')" + merged_commit_sha="$(gh pr view "$resume_pr_number" --json mergeCommit --jq '.mergeCommit.oid // empty')" + [ "$pr_state" = "MERGED" ] || die "PR #$resume_pr_number is $pr_state, not MERGED. Use the normal standard release path until the pull request has merged." + [ "$pr_base_branch" = "$base_branch" ] || die "PR #$resume_pr_number merged into $pr_base_branch, but this release expects base branch $base_branch. Pass the matching --base-branch value before resuming." + [ -n "$merged_commit_sha" ] || die "GitHub did not return the merge commit for merged PR #$resume_pr_number. Wait for GitHub to finish recording the merge, then rerun the resume command." + fi + + fetch_merged_base_commit "$merged_commit_sha" + create_release_tag "$merged_commit_sha" + push_release_tag + create_github_release + cleanup_merged_branches "$release_branch_name" + log "Standard release resume completed successfully for $RELEASE_TAG from merged PR #$resume_pr_number." +} + +run_standard_release() { + ensure_git_repo + ensure_gh_cli + ensure_semver_tag + ensure_remote_ci_mode + + if [ -n "$resume_pr_number" ]; then + resume_merged_release + return 0 + fi + + branch_name="$(ensure_branch_release_context)" + ensure_clean_worktree + + if [ "$skip_validate" != "true" ]; then + sh "$SELF_DIR/validate-all.sh" + fi + + run_version_bump + ensure_clean_worktree + push_release_branch "$branch_name" + create_or_update_pr "$branch_name" + pr_number="$PR_NUMBER" + wait_for_initial_pr_checks "$pr_number" + if defer_remote_ci_if_requested "$pr_number" "$branch_name"; then + log "Standard release flow paused before remote CI watch for $RELEASE_TAG." + return 0 + fi + watch_ci "$pr_number" + check_pr_comments "$pr_number" + merge_pr "$pr_number" + fetch_merged_base_commit "$MERGED_COMMIT_SHA" + create_release_tag "$MERGED_COMMIT_SHA" + push_release_tag + create_github_release + cleanup_merged_branches "$branch_name" + log "Standard release flow completed successfully for $RELEASE_TAG." +} + +if [ "$mode" = "standard" ]; then + run_standard_release + exit 0 +fi + +if [ "$skip_validate" != "true" ]; then + sh "$SELF_DIR/validate-all.sh" +fi + +log "Running repo-maintenance release flow in $REPO_MAINTENANCE_RELEASE_MODE mode for $RELEASE_TAG with the $REPO_MAINTENANCE_PROFILE profile." +run_dispatch_dir "$SELF_DIR/release" "release" + +if [ "$REPO_MAINTENANCE_RELEASE_MODE" = "submodule" ]; then + log "Submodule release finished. Update the parent repository's submodule pointer in a separate follow-up commit." +fi + +log "Repo-maintenance release flow completed successfully." diff --git a/scripts/repo-maintenance/release/10-preflight.sh b/scripts/repo-maintenance/release/10-preflight.sh new file mode 100755 index 0000000..cf6f0a3 --- /dev/null +++ b/scripts/repo-maintenance/release/10-preflight.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +ensure_git_repo + +case "${REPO_MAINTENANCE_RELEASE_MODE:-}" in + standard|submodule) + ;; + *) + die "Release mode must be standard or submodule." + ;; +esac + +is_valid_semver_tag "${RELEASE_TAG:-}" || die "Release tag must use strict vX.Y.Z SemVer syntax, with optional prerelease and build metadata identifiers." + +branch_name="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD || true)" +[ -n "$branch_name" ] || die "Release workflow requires a named branch instead of detached HEAD." + +status_output="$(git -C "$REPO_ROOT" status --porcelain)" +[ -z "$status_output" ] || die "Release workflow requires a clean worktree before tagging." + +if [ "${REPO_MAINTENANCE_RELEASE_MODE:-}" = "submodule" ]; then + superproject_root="$(git -C "$REPO_ROOT" rev-parse --show-superproject-working-tree || true)" + [ -n "$superproject_root" ] || die "Submodule release mode requires this repository to be checked out as a git submodule." +fi diff --git a/scripts/repo-maintenance/release/20-tag-release.sh b/scripts/repo-maintenance/release/20-tag-release.sh new file mode 100755 index 0000000..2d66a79 --- /dev/null +++ b/scripts/repo-maintenance/release/20-tag-release.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" +tag_sha="$(git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" + +if [ -n "$tag_sha" ]; then + tag_commit_sha="$(git -C "$REPO_ROOT" rev-list -n 1 "$RELEASE_TAG")" + [ "$tag_commit_sha" = "$head_sha" ] || die "Tag $RELEASE_TAG already exists and does not point at HEAD." + log "Tag $RELEASE_TAG already points at HEAD." + exit 0 +fi + +if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then + log "Would create annotated tag $RELEASE_TAG at HEAD." + exit 0 +fi + +git -C "$REPO_ROOT" tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" +log "Created annotated tag $RELEASE_TAG." diff --git a/scripts/repo-maintenance/release/30-push-release.sh b/scripts/repo-maintenance/release/30-push-release.sh new file mode 100755 index 0000000..54de388 --- /dev/null +++ b/scripts/repo-maintenance/release/30-push-release.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +branch_name="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD)" + +if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then + log "Would push branch $branch_name and tag $RELEASE_TAG to origin." + exit 0 +fi + +git -C "$REPO_ROOT" push -u origin "$branch_name" +wait_for_remote_branch "$branch_name" +git -C "$REPO_ROOT" push origin "$RELEASE_TAG" +wait_for_remote_tag "$RELEASE_TAG" +log "Pushed branch $branch_name and tag $RELEASE_TAG." diff --git a/scripts/repo-maintenance/release/40-github-release.sh b/scripts/repo-maintenance/release/40-github-release.sh new file mode 100755 index 0000000..4f453d5 --- /dev/null +++ b/scripts/repo-maintenance/release/40-github-release.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +if [ "${REPO_MAINTENANCE_SKIP_GH_RELEASE:-false}" = "true" ]; then + log "Skipping GitHub release creation because --skip-gh-release was requested." + exit 0 +fi + +if ! command -v gh >/dev/null 2>&1; then + die "GitHub release creation requires the GitHub CLI gh. Install gh or rerun with --skip-gh-release only when intentionally publishing the tag without a GitHub release object." +fi + +if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then + prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" + log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag${prerelease_flag:+ $prerelease_flag}." + exit 0 +fi + +if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + verify_github_release_prerelease_metadata "$RELEASE_TAG" + log "GitHub release $RELEASE_TAG already exists." + exit 0 +fi + +prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" +# shellcheck disable=SC2086 +gh release create "$RELEASE_TAG" --verify-tag --generate-notes $prerelease_flag +log "Created GitHub release $RELEASE_TAG." +wait_for_github_release "$RELEASE_TAG" +verify_github_release_prerelease_metadata "$RELEASE_TAG" diff --git a/scripts/repo-maintenance/sync-shared.sh b/scripts/repo-maintenance/sync-shared.sh new file mode 100755 index 0000000..5a00c94 --- /dev/null +++ b/scripts/repo-maintenance/sync-shared.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" +. "$SELF_DIR/lib/common.sh" + +load_profile_env +ensure_git_repo +log "Running repo-maintenance shared sync from $REPO_ROOT with the $REPO_MAINTENANCE_PROFILE profile." +run_dispatch_dir "$SELF_DIR/syncing" "sync" +log "Repo-maintenance shared sync completed successfully." diff --git a/scripts/repo-maintenance/syncing/README.md b/scripts/repo-maintenance/syncing/README.md new file mode 100644 index 0000000..9de6357 --- /dev/null +++ b/scripts/repo-maintenance/syncing/README.md @@ -0,0 +1,31 @@ +# Repo-Maintenance Syncing Steps + +Small helper surface for deterministic repo-maintenance sync hooks. + +## Overview + +This directory holds repo-specific shell hooks that the shared repo-maintenance sync entrypoint can discover and run. + +### Motivation + +It exists so a repository can keep local sync follow-up steps in one predictable place without forking the shared sync entrypoint itself. + +## Setup + +Add repo-specific `.sh` files here only when the repository needs deterministic shared-sync follow-up steps. The dispatcher invokes each hook through `sh`, so executable permission is not required. + +## Usage + +The top-level `scripts/repo-maintenance/sync-shared.sh` entrypoint discovers and runs every `*.sh` file in this directory in lexical order. + +## Development + +Keep each hook small, deterministic, and specific to the owning repository's guidance or packaging sync needs. + +## Verification + +Run the owning repository's shared sync entrypoint and confirm the expected repo-specific hooks execute in lexical order. + +## License + +Covered by the parent repository license. diff --git a/scripts/repo-maintenance/validate-all.sh b/scripts/repo-maintenance/validate-all.sh new file mode 100755 index 0000000..583696b --- /dev/null +++ b/scripts/repo-maintenance/validate-all.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" +. "$SELF_DIR/lib/common.sh" + +load_profile_env +load_env_file "$SELF_DIR/config/validation.env" +ensure_git_repo +log "Running repo-maintenance validation from $REPO_ROOT with the $REPO_MAINTENANCE_PROFILE profile." +run_dispatch_dir "$SELF_DIR/validations" "validation" +log "Repo-maintenance validation completed successfully." diff --git a/scripts/repo-maintenance/validations/10-toolkit-layout.sh b/scripts/repo-maintenance/validations/10-toolkit-layout.sh new file mode 100755 index 0000000..7103b20 --- /dev/null +++ b/scripts/repo-maintenance/validations/10-toolkit-layout.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +for required in \ + "$REPO_MAINTENANCE_ROOT/validate-all.sh" \ + "$REPO_MAINTENANCE_ROOT/sync-shared.sh" \ + "$REPO_MAINTENANCE_ROOT/release.sh" \ + "$REPO_MAINTENANCE_ROOT/lib/common.sh" \ + "$REPO_MAINTENANCE_ROOT/config/profile.env" +do + [ -f "$required" ] || die "maintain-project-repo is missing the required file $required." +done diff --git a/scripts/repo-maintenance/validations/20-agents-guidance.sh b/scripts/repo-maintenance/validations/20-agents-guidance.sh new file mode 100755 index 0000000..2f775a7 --- /dev/null +++ b/scripts/repo-maintenance/validations/20-agents-guidance.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +if [ "${REPO_MAINTENANCE_REQUIRE_AGENTS:-true}" != "true" ]; then + log "Skipping AGENTS.md validation because REPO_MAINTENANCE_REQUIRE_AGENTS is disabled." + exit 0 +fi + +agents_path="$REPO_ROOT/AGENTS.md" +[ -f "$agents_path" ] || die "Expected $agents_path to exist so maintain-project-repo has repo guidance to complement." +[ -s "$agents_path" ] || die "Expected $agents_path to be non-empty." + +for needle in \ + "scripts/repo-maintenance/validate-all.sh" \ + "scripts/repo-maintenance/sync-shared.sh" \ + "scripts/repo-maintenance/release.sh" +do + grep -F "$needle" "$agents_path" >/dev/null 2>&1 || die "Expected $agents_path to mention $needle so the maintainer validation, sync, and release entrypoints stay discoverable." +done diff --git a/scripts/repo-maintenance/validations/30-ci-wrapper.sh b/scripts/repo-maintenance/validations/30-ci-wrapper.sh new file mode 100755 index 0000000..e6815be --- /dev/null +++ b/scripts/repo-maintenance/validations/30-ci-wrapper.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +workflow_path="$REPO_ROOT/.github/workflows/validate-repo-maintenance.yml" + +if [ ! -f "$workflow_path" ]; then + log "Skipping CI wrapper validation because $workflow_path is not present." + exit 0 +fi + +grep -Fq "scripts/repo-maintenance/validate-all.sh" "$workflow_path" || die "Expected $workflow_path to call scripts/repo-maintenance/validate-all.sh." diff --git a/scripts/repo-maintenance/validations/35-shell-toolkit.sh b/scripts/repo-maintenance/validations/35-shell-toolkit.sh new file mode 100644 index 0000000..79481c7 --- /dev/null +++ b/scripts/repo-maintenance/validations/35-shell-toolkit.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +log "Checking repo-maintenance shell syntax." +find "$REPO_MAINTENANCE_ROOT" -type f -name '*.sh' -exec sh -n {} \; + +log "Checking strict SemVer tag handling." +for valid_tag in \ + v0.0.0 \ + v1.2.3 \ + v1.2.3-alpha \ + v1.2.3-alpha.1 \ + v1.2.3+build.5 \ + v1.2.3-rc.1+build.5 +do + is_valid_semver_tag "$valid_tag" || die "Strict SemVer validation rejected valid tag $valid_tag." +done + +for invalid_tag in \ + 1.2.3 \ + v1.2 \ + v01.2.3 \ + v1.02.3 \ + v1.2.03 \ + v1.2.3- \ + v1.2.3-01 \ + v1.2.3+ \ + v1.2.3_alpha +do + if is_valid_semver_tag "$invalid_tag"; then + die "Strict SemVer validation accepted invalid tag $invalid_tag." + fi +done + +log "Repo-maintenance shell syntax and SemVer checks passed." diff --git a/scripts/repo-maintenance/validations/40-swift-package.sh b/scripts/repo-maintenance/validations/40-swift-package.sh new file mode 100755 index 0000000..72c977b --- /dev/null +++ b/scripts/repo-maintenance/validations/40-swift-package.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +command -v swift >/dev/null 2>&1 || die "Swift package validation could not find the Swift CLI on PATH. Select an Xcode command-line toolchain before running validate-all.sh." + +log "Building the Spelunking Swift package." +(cd "$REPO_ROOT" && swift build) + +log "Testing the Spelunking Swift package after the build completed." +(cd "$REPO_ROOT" && swift test) diff --git a/scripts/repo-maintenance/validations/50-markdown-links.sh b/scripts/repo-maintenance/validations/50-markdown-links.sh new file mode 100755 index 0000000..257421e --- /dev/null +++ b/scripts/repo-maintenance/validations/50-markdown-links.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +command -v python3 >/dev/null 2>&1 || die "Markdown link validation could not find python3 on PATH. Install Python 3 before running validate-all.sh." + +log "Checking repository Markdown for missing relative link targets." +python3 - "$REPO_ROOT" <<'PY' +from __future__ import annotations + +import re +import sys +from pathlib import Path +from urllib.parse import unquote + +repo_root = Path(sys.argv[1]).resolve() +ignored_roots = {".build", ".git", ".swiftpm", "DerivedData"} +link_pattern = re.compile(r"(?"): + raw_destination = raw_destination[1:-1] + destination = raw_destination.split(maxsplit=1)[0] + if not destination or destination.startswith(("#", "http://", "https://", "mailto:")): + continue + + path_text = unquote(destination.split("#", 1)[0]) + if not path_text: + continue + + if path_text.startswith("/"): + target = (repo_root / path_text.lstrip("/")).resolve() + else: + target = (markdown_path.parent / path_text).resolve() + if not target.exists(): + missing.append( + f"{markdown_path.relative_to(repo_root)}:{line_number}: " + f"relative Markdown link target does not exist: {destination}" + ) + +if missing: + for finding in missing: + print(f"ERROR: {finding}", file=sys.stderr) + raise SystemExit(1) + +print("Markdown relative link targets are valid.") +PY diff --git a/scripts/repo-maintenance/validations/60-pii-safety.sh b/scripts/repo-maintenance/validations/60-pii-safety.sh new file mode 100755 index 0000000..e3109ff --- /dev/null +++ b/scripts/repo-maintenance/validations/60-pii-safety.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +findings_file=$(mktemp "${TMPDIR:-/tmp}/spelunking-pii-findings.XXXXXX") +trap 'rm -f "$findings_file"' EXIT HUP INT TERM + +scan_pattern() { + label="$1" + pattern="$2" + + matches=$(git -C "$REPO_ROOT" grep -n -I -E "$pattern" -- . \ + ':!scripts/repo-maintenance/validations/60-pii-safety.sh' 2>/dev/null || true) + if [ -n "$matches" ]; then + printf '%s\n' "$label" >>"$findings_file" + printf '%s\n' "$matches" >>"$findings_file" + fi +} + +log "Checking tracked repository text for personal and machine-specific identifiers." + +scan_pattern "Home-directory path" '/Users/[^/[:space:]]+|/home/[^/[:space:]]+' +scan_pattern "Email address" '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+[.][A-Za-z]{2,}' +scan_pattern "Phone number" '([+][0-9]{1,3}[ .-]?)?[(][0-9]{3}[)][ .-]?[0-9]{3}[ .-][0-9]{4}' +scan_pattern "Personal signing identity" 'Authority=(Apple Development|Developer ID Application):' +scan_pattern "Developer team identifier" 'TeamIdentifier=[A-Z0-9]{10}' +scan_pattern "Certificate fingerprint" '(certificate hash|SHA-1 fingerprint).*([0-9A-Fa-f]{40})' +scan_pattern "Known local identifier" 'GMBP16|G15PM|AMRC3N39SQ|BC73766F69|com[.]galewilliams|Gale Williams' + +if [ -s "$findings_file" ]; then + while IFS= read -r finding; do + warn "$finding" + done <"$findings_file" + die "PII safety validation found personal or machine-specific identifiers in tracked files. Redact each reported value while preserving the technical result, then rerun validate-all.sh." +fi + +log "Tracked repository text passed the PII safety checks."