diff --git a/.github/workflows/validate-repo-maintenance.yml b/.github/workflows/validate-repo-maintenance.yml new file mode 100644 index 0000000..5e05590 --- /dev/null +++ b/.github/workflows/validate-repo-maintenance.yml @@ -0,0 +1,26 @@ +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 + - 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/.spi.yml b/.spi.yml new file mode 100644 index 0000000..3dd7e81 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [MaxVol] 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/AGENTS.md b/AGENTS.md index 03223c2..3599ad8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,3 +125,42 @@ - Validate both Debug and Release paths when optimization or packaging differences matter, and treat tagged releases as a cue to verify the Release artifact path before publishing. - Keep `xcodebuild` invocations explicit and reproducible (always pass scheme, destination or SDK, and configuration when relevant). - Prefer deterministic non-interactive CLI usage in automation/CI for both `swift package` and `xcodebuild`. + +## Swift Package Workflow + +- Use `swift build` and `swift test` as the default first-pass validation commands for this package. +- Use `bootstrap-swift-package` when a new Swift package repo still needs to be created from scratch. +- Use `sync-swift-package-guidance` when the repo guidance for this package drifts and needs to be refreshed or merged forward. +- Re-run `sync-swift-package-guidance` after substantial package-workflow or plugin updates so local guidance stays aligned. +- Use `swift-package-build-run-workflow` for manifest, dependency, plugin, resource, Metal-distribution, build, and run work when `Package.swift` is the source of truth. +- Use `swift-package-testing-workflow` for Swift Testing, XCTest holdouts, `.xctestplan`, fixtures, and package test diagnosis. +- Use `scripts/repo-maintenance/validate-all.sh` for local maintainer validation and `scripts/repo-maintenance/sync-shared.sh` for repo-local sync steps. +- Use `scripts/repo-maintenance/release.sh --mode standard --version vX.Y.Z` from a feature branch or worktree only when the task is actually a protected-main release, publish, merge, tag, or release-PR preparation. +- Do not run the standard release workflow from `main`; when a protected-main release is explicitly requested, let it validate, bump versions, tag, push the branch and tag, open the release PR, watch CI, address valid PR comments or record out-of-scope concerns in `ROADMAP.md`, merge to protected `main`, fast-forward local `main`, and clean up stale branches. +- Treat `scripts/repo-maintenance/config/profile.env` as the installed `maintain-project-repo` profile marker, and keep it on the `swift-package` profile for plain package repos. +- Read relevant SwiftPM, Swift, and Apple documentation before proposing package-structure, dependency, manifest, concurrency, or architecture changes. +- Prefer Xcode MCP DocumentationSearch or local Swift docs first for Apple-owned Swift and SDK behavior; use Dash MCP or Dash HTTP next for installed Swift package, SwiftPM, and non-Apple docs; then use official Swift or Apple docs when local docs are insufficient. +- When SwiftPM behavior, manifest syntax, package plugins, resources, products, targets, or dependency rules matter, prefer the Dash.app docset workflow with the `swiftlang/swift-package-manager` docset first; fall back to the canonical `swiftlang/swift-package-manager` GitHub repository only when the local docset is unavailable or insufficient. +- Prefer the simplest correct Swift that is easiest to read and reason about. +- Prefer synthesized and framework-provided behavior over extra wrappers and boilerplate. +- For public Swift APIs, treat streamlined, compact, ergonomic call sites as the only acceptable default; prefer optional parameters with explicit default values over additional methods or overloads when the difference is optional behavior on the same operation. +- When a public function, initializer, or method reaches four or more arguments or parameters, strongly prefer a named typed `struct` request, options, or configuration value so call sites stay readable and future additions do not multiply overloads. +- Prefer enums, enum cases with associated values, and narrow typed values over strings, booleans, sentinel values, or parallel parameters whenever the domain has a closed or meaningful set of choices. +- Keep data flow straight and dependency direction unidirectional. +- Treat `Package.swift` as the source of truth for package structure, targets, products, and dependencies. +- Prefer `swift package` subcommands for structural package edits before manually editing `Package.swift`. +- Edit `Package.swift` intentionally and keep it readable; agents may modify it when package structure, targets, products, or dependencies need to change, and should try to keep package graph updates consolidated in one change when possible. +- Keep `Package.swift` explicit about its package-wide Swift language mode. On current Swift 6-era manifests, prefer `swiftLanguageModes: [.v6]` as the default declaration, treat `swiftLanguageVersions` as a legacy alias used only when an older manifest surface requires it, and keep the supported Swift toolchain window focused on the latest stable minor and previous stable minor. Treat Swift `6.2` as the current minimum floor for trait-enabled manifests, not as a ceiling; use newer stable Swift toolchains when available and validated, and refresh this guidance when the maintained floor or window changes. Do not lower `// swift-tools-version:` below `6.2` without an explicit repo policy and a matching guidance update. +- Keep `swift-configuration` as the default configuration dependency for Swift packages unless the package has a concrete reason to remove it. The preferred manifest shape depends on `https://github.com/apple/swift-configuration` from `1.2.0`, enables the `.defaults`, `Reloading`, `YAML`, and `CommandLineArguments` package traits, and adds the `Configuration` product to the primary target. Add the `PropertyList` trait when the package should parse property-list configuration, and add the `Logging` trait when configuration access should integrate with `SwiftLog.Logger`. +- Keep dependency provenance concise but explicit enough for another contributor to fetch the same package: use package-manager, package-registry, GitHub URL, or other real remote repository requirements, and do not commit machine-local dependency paths such as `/Users/...`, `~/...`, `../...`, local worktrees, or private checkout paths. Avoid branch- or revision-based requirements unless the user explicitly asks for that level of control. +- Treat `Package.resolved` and similar package-manager outputs as generated files; do not hand-edit them. +- Prefer Swift Testing by default unless an external constraint requires XCTest. +- Use `apple-ui-accessibility-workflow` when the package work crosses into SwiftUI accessibility semantics, Apple UI accessibility review, or UIKit/AppKit accessibility bridge behavior. +- Keep package resources under the owning target tree, declare them intentionally with `Resource.process(...)`, `Resource.copy(...)`, `Resource.embedInCode(...)`, and load them through `Bundle.module`. +- Keep test fixtures as test-target resources instead of relying on the working directory. +- Bundle precompiled Metal artifacts such as `.metallib` files as explicit resources when they ship with the package, and prefer `xcode-build-run-workflow` when shader compilation or Apple-managed Metal toolchain behavior matters. +- Prefer normal SwiftPM parallel test execution for ordinary Swift Testing and XCTest runs. Do not serialize regular package tests just because they use Swift, XCTest, async tests, fixtures, or test plans. +- Treat tests that load large local AI or ML models, especially models over 500 million parameters, as heavy system-resource tests. Run those tests sequentially, one at a time, and call `unload_models` on Gale's live TTS service before the heavy run and `reload_models` after it ends, even when the run fails or is interrupted. +- Validate both Debug and Release paths when optimization or packaging differences matter, and treat tagged releases as a cue to verify the Release artifact path before publishing. +- Prefer `xcode-build-run-workflow` or `xcode-testing-workflow` only when package work needs Xcode-managed SDK, toolchain, or test behavior. +- Keep runtime UI accessibility verification and XCUITest follow-through in `xcode-testing-workflow` rather than treating package-side testing as a substitute for live UI verification. diff --git a/Package.swift b/Package.swift index df931a5..b526725 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,11 @@ let package = Package( // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "MaxVol" + name: "MaxVol", + cSettings: [ + .define("ACCELERATE_NEW_LAPACK"), + .define("ACCELERATE_LAPACK_ILP64"), + ] ), .testTarget( name: "MaxVolTests", diff --git a/README.md b/README.md index 940ad32..359240c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ following the same shape once the API is settled. - Initialize pivots with `dgetrf`. - Solve for expansion coefficients with triangular solves. - Iterate row swaps using the standard rank-one update with `dger`. - - Return selected row indices, coefficient matrix, and iteration count. + - Return selected row indices, coefficient matrix, iteration count, and + whether the coefficient tolerance was reached. 3. Add RectMaxVol on top of square MaxVol. - Start from square MaxVol pivots. @@ -34,7 +35,8 @@ following the same shape once the API is settled. - Verify reconstruction `A ~= C * A[pivots, :]`. - Check pivot count, uniqueness, and bounds. - Cover identity, tall random, rank-deficient, tolerance, and max-iteration cases. - - Compare small deterministic fixtures against a reference Python or Julia result. + - Compare small deterministic fixtures against reference Python, Julia, or R + results. 5. Add performance-focused refinements only after the behavior is stable. - Avoid repeated temporary allocations in the swap loop. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..6eacf8d --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,95 @@ +# MaxVol Roadmap + +## Current Focus + +- Ship `v0.5.0` with square MaxVol for real-valued `Double` matrices backed by + modern Accelerate BLAS/LAPACK. +- Keep Swift Testing as the package test surface and require reference fixtures + before broadening the algorithm surface. +- Treat public API compatibility as pre-1.0 until RectMaxVol, Float support, and + reference parity are complete. + +## Algorithm Milestones + +1. Implement square MaxVol for `Double`. + - Accept a tall or square `DenseColumnMajorMatrix` where + `rows >= columns`. + - Initialize pivots with Accelerate LAPACK LU factorization. + - Solve expansion coefficients with Accelerate triangular solve routines. + - Iterate row swaps with BLAS rank-one updates. + - Return selected row indices, coefficient matrix, iteration count, and + convergence status. + +2. Add RectMaxVol for `Double`. + - Start from square MaxVol selected rows. + - Append rows while coefficient row norms exceed tolerance. + - Support explicit `minRows` and `maxRows` bounds. + - Reuse coefficient-update primitives from square MaxVol where practical. + +3. Add `Float` support after the `Double` API is stable. + - Mirror the `Double` API shape. + - Map to the matching single-precision Accelerate entry points. + - Keep shared validation and result semantics identical. + +4. Add reference-parity fixtures for each supported algorithm. + - Keep small deterministic fixtures generated from `maxvolpy`, `Maxvol.jl`, + or an R implementation. + - Record selected rows, coefficients, iteration counts, and convergence + status for fixture matrices that require zero, one, and multiple swaps. + - Include independent reconstruction checks for every fixture. + +## Test Coverage + +- Cover matrix storage, checked access, shape validation, and result invariants. +- Verify selected row count, uniqueness, and bounds. +- Verify reconstruction: `A ~= C * A[selectedRows, :]`. +- Cover identity, square, tall deterministic, rank-deficient, tolerance, and + maximum-iteration cases. +- Keep reference fixtures generated from upstream Python, Julia, or R + implementations when algorithm behavior changes. +- Add randomized orthonormal-matrix tests similar to `Maxvol.jl` before `1.0.0`. +- Add Release-mode validation once behavior depends on optimization-sensitive + Accelerate calls. +- Keep validation clean under the modern Accelerate `ACCELERATE_NEW_LAPACK` and + `ACCELERATE_LAPACK_ILP64` import surface. + +## Documentation + +- Maintain the DocC catalog for the package. +- Document column-major storage and the relationship to Accelerate/LAPACK leading + dimensions. +- Document square MaxVol and RectMaxVol usage with small examples. +- Keep algorithm limitations and non-goals visible before `1.0.0`. +- Keep public API docs aligned with tested behavior. +- Add a DocC article that explains tolerance, convergence status, and + iteration-limited partial results before `1.0.0`. + +## Swift Package Index + +- Keep SPI readiness checks in the release path. +- Keep `.spi.yml` aligned with DocC targets when package documentation changes. +- Verify `swift package dump-package`, `swift build`, `swift test`, and DocC + generation before submitting to Swift Package Index. +- Submit only after the GitHub repository is public and a SemVer tag exists. + +## GitHub Publication + +- Keep the public `gaelic-ghost/MaxVol` repository aligned with SemVer tags. +- Merge focused pull requests into `main` only after serial `swift build`, + `swift test`, repo-maintenance validation, and DocC conversion pass. +- Submit to Swift Package Index after the first public SemVer tag is pushed. + +## Before `1.0.0` + +- Implement and test RectMaxVol for `Double`. +- Add `Float` support with the same API shape and reference fixtures. +- Decide whether complex-valued matrices are in scope for `1.0.0` or explicitly + post-1.0. +- Add broader randomized numerical tests, including orthonormal tall matrices + and near-rank-deficient cases. +- Add Release-mode validation for optimization-sensitive Accelerate behavior. +- Add performance benchmarks for allocation count and row-swap throughput. +- Expand DocC with algorithm notes, limitations, and reference-fixture + provenance. +- Submit the tagged public package to Swift Package Index and verify rendered + documentation. diff --git a/Sources/MaxVol/DenseColumnMajorMatrix.swift b/Sources/MaxVol/DenseColumnMajorMatrix.swift new file mode 100644 index 0000000..1407745 --- /dev/null +++ b/Sources/MaxVol/DenseColumnMajorMatrix.swift @@ -0,0 +1,135 @@ +/// A dense matrix stored in column-major order. +/// +/// Column-major storage keeps values contiguous by column, matching the layout +/// expected by BLAS and LAPACK routines in Accelerate. +public struct DenseColumnMajorMatrix: Sendable { + /// The number of matrix rows. + public let rows: Int + + /// The number of matrix columns. + public let columns: Int + + /// The matrix values in column-major order. + public internal(set) var values: [Scalar] + + /// The physical row stride between adjacent columns. + public var leadingDimension: Int { rows } + + /// Creates a matrix from values that are already arranged by column. + /// + /// The value at `(row, column)` is stored at + /// `column * leadingDimension + row`. + public init(rows: Int, columns: Int, columnMajorValues values: [Scalar]) throws { + try Self.validateDimensions(rows: rows, columns: columns) + try Self.validateValueCount(rows: rows, columns: columns, valueCount: values.count) + + self.rows = rows + self.columns = columns + self.values = values + } + + /// Creates a matrix from values arranged by row. + /// + /// The initializer converts the input into column-major storage so the + /// resulting matrix can be passed directly to Accelerate-backed routines. + public init(rows: Int, columns: Int, rowMajorValues values: [Scalar]) throws { + try Self.validateDimensions(rows: rows, columns: columns) + try Self.validateValueCount(rows: rows, columns: columns, valueCount: values.count) + + self.rows = rows + self.columns = columns + self.values = (0.. 0, columns > 0 else { + throw MaxVolError.invalidDimensions(rows: rows, columns: columns) + } + guard !rows.multipliedReportingOverflow(by: columns).overflow else { + throw MaxVolError.invalidDimensions(rows: rows, columns: columns) + } + } + + private static func validateValueCount(rows: Int, columns: Int, valueCount: Int) throws { + let expectedCount = rows * columns + guard valueCount == expectedCount else { + throw MaxVolError.malformedMatrix( + rows: rows, + columns: columns, + expectedCount: expectedCount, + actualCount: valueCount + ) + } + } + + /// Returns the value at a checked row and column index. + public func value(row: Int, column: Int) throws -> Scalar { + try validateIndex(row: row, column: column) + return self[row: row, column: column] + } + + /// Replaces the value at a checked row and column index. + public mutating func setValue(_ value: Scalar, row: Int, column: Int) throws { + try validateIndex(row: row, column: column) + self[row: row, column: column] = value + } + + /// Returns the values in a checked row index. + public func row(_ row: Int) throws -> [Scalar] { + try validateRow(row) + return (0.. ArraySlice { + try validateColumn(column) + let start = column * leadingDimension + return values[start.. Self { + guard rows >= columns else { + throw MaxVolError.nonTallMatrix(rows: rows, columns: columns) + } + + return self + } + + subscript(row row: Int, column column: Int) -> Scalar { + get { values[column * leadingDimension + row] } + set { values[column * leadingDimension + row] = newValue } + } + + private func validateIndex(row: Int, column: Int) throws { + guard (0.., + options: MaxVolOptions = MaxVolOptions() +) throws -> MaxVolResult { + let input = try matrix.validatedTallMatrix() + let options = try options.validated() + var selectedRows = try initialPivotRows(for: input) + var coefficients = try expansionCoefficients(for: input, selectedRows: selectedRows) + var iterations = 0 + + while true { + let pivot = maximumMagnitude(in: coefficients) + guard pivot.value > options.tolerance else { + return try MaxVolResult( + selectedRows: selectedRows, + coefficients: coefficients, + iterations: iterations, + converged: true + ) + } + guard iterations < options.maxIterations else { + return try MaxVolResult( + selectedRows: selectedRows, + coefficients: coefficients, + iterations: iterations, + converged: false + ) + } + + selectedRows[pivot.column] = pivot.row + try replaceBasisRow( + pivotRow: pivot.row, + pivotColumn: pivot.column, + coefficients: &coefficients + ) + iterations += 1 + } +} + +private struct CoefficientPivot { + let row: Int + let column: Int + let value: Double +} + +private func initialPivotRows(for matrix: DenseColumnMajorMatrix) throws -> [Int] { + var factorization = matrix.values + var rowCount = try lapackInt(matrix.rows) + var columnCount = try lapackInt(matrix.columns) + var leadingDimension = try lapackInt(matrix.leadingDimension) + var pivots = Array(repeating: LAPACKInt(0), count: matrix.columns) + var info = LAPACKInt(0) + + factorization.withUnsafeMutableBufferPointer { factorBuffer -> Void in + pivots.withUnsafeMutableBufferPointer { pivotBuffer -> Void in + dgetrf_( + &rowCount, + &columnCount, + factorBuffer.baseAddress, + &leadingDimension, + pivotBuffer.baseAddress, + &info + ) + } + } + + try validateLapackInfo(info, routine: "dgetrf", rankDeficientInfoIsPivot: true) + + var permutation = Array(0.., + selectedRows: [Int] +) throws -> DenseColumnMajorMatrix { + let rank = selectedRows.count + var basisValues = selectedRowsForBasis(matrix: matrix, selectedRows: selectedRows) + var rowRankDimension = try lapackInt(rank) + var columnRankDimension = try lapackInt(rank) + var leadingDimension = try lapackInt(rank) + var pivots = Array(repeating: LAPACKInt(0), count: rank) + var info = LAPACKInt(0) + + basisValues.withUnsafeMutableBufferPointer { basisBuffer -> Void in + pivots.withUnsafeMutableBufferPointer { pivotBuffer -> Void in + dgetrf_( + &rowRankDimension, + &columnRankDimension, + basisBuffer.baseAddress, + &leadingDimension, + pivotBuffer.baseAddress, + &info + ) + } + } + + try validateLapackInfo(info, routine: "dgetrf", rankDeficientInfoIsPivot: true) + try validateNonsingularFactorization(basisValues, dimension: rank) + + var transposedRightHandSide = transposedValues(matrix) + var transpose = CChar(UInt8(ascii: "T")) + var solveRankDimension = try lapackInt(rank) + var basisLeadingDimension = try lapackInt(rank) + var rightHandSideLeadingDimension = try lapackInt(rank) + var rightHandSides = try lapackInt(matrix.rows) + info = 0 + + basisValues.withUnsafeMutableBufferPointer { basisBuffer -> Void in + pivots.withUnsafeMutableBufferPointer { pivotBuffer -> Void in + transposedRightHandSide.withUnsafeMutableBufferPointer { rightHandSideBuffer -> Void in + dgetrs_( + &transpose, + &solveRankDimension, + &rightHandSides, + basisBuffer.baseAddress, + &basisLeadingDimension, + pivotBuffer.baseAddress, + rightHandSideBuffer.baseAddress, + &rightHandSideLeadingDimension, + &info + ) + } + } + } + + try validateLapackInfo(info, routine: "dgetrs", rankDeficientInfoIsPivot: false) + + let coefficientValues = (0.., + selectedRows: [Int] +) -> [Double] { + (0..) -> [Double] { + (0..) -> CoefficientPivot { + var pivot = CoefficientPivot(row: 0, column: 0, value: abs(coefficients[row: 0, column: 0])) + + for column in 0.. pivot.value { + pivot = CoefficientPivot(row: row, column: column, value: magnitude) + } + } + } + + return pivot +} + +private func replaceBasisRow( + pivotRow: Int, + pivotColumn: Int, + coefficients: inout DenseColumnMajorMatrix +) throws { + let gamma = coefficients[row: pivotRow, column: pivotColumn] + guard gamma != 0 else { + throw MaxVolError.rankDeficient(pivot: pivotColumn) + } + + let replacementColumn = (0.. Void in + replacementRow.withUnsafeBufferPointer { rowBuffer -> Void in + coefficients.values.withUnsafeMutableBufferPointer { coefficientBuffer -> Void in + cblas_dger( + CblasColMajor, + rowCount, + columnCount, + -1, + columnBuffer.baseAddress, + increment, + rowBuffer.baseAddress, + increment, + coefficientBuffer.baseAddress, + leadingDimension + ) + } + } + } + + for row in 0.. LAPACKInt { + guard value <= Int(LAPACKInt.max) else { + throw MaxVolError.invalidDimensions(rows: value, columns: value) + } + + return LAPACKInt(value) +} + +private func validateNonsingularFactorization(_ values: [Double], dimension: Int) throws { + let scale = max(values.map(abs).max() ?? 0, 1) + let threshold = Double.ulpOfOne * Double(dimension) * scale + + for pivot in 0.. threshold else { + throw MaxVolError.rankDeficient(pivot: pivot + 1) + } + } +} + +private func validateLapackInfo( + _ info: LAPACKInt, + routine: String, + rankDeficientInfoIsPivot: Bool +) throws { + if info < 0 { + throw MaxVolError.lapackFailure(routine: routine, info: Int(info)) + } + if info > 0 { + if rankDeficientInfoIsPivot { + throw MaxVolError.rankDeficient(pivot: Int(info)) + } + throw MaxVolError.lapackFailure(routine: routine, info: Int(info)) + } +} diff --git a/Sources/MaxVol/MaxVolError.swift b/Sources/MaxVol/MaxVolError.swift new file mode 100644 index 0000000..7486b7f --- /dev/null +++ b/Sources/MaxVol/MaxVolError.swift @@ -0,0 +1,85 @@ +/// Errors thrown by MaxVol matrix validation and Accelerate-backed computation. +public enum MaxVolError: Error, Equatable, Sendable { + /// Matrix dimensions must be positive and fit in addressable storage. + case invalidDimensions(rows: Int, columns: Int) + + /// The value buffer count does not match the requested matrix shape. + case malformedMatrix(rows: Int, columns: Int, expectedCount: Int, actualCount: Int) + + /// A row and column pair is outside the matrix bounds. + case matrixIndexOutOfBounds(row: Int, column: Int, rows: Int, columns: Int) + + /// A row index is outside the matrix bounds. + case rowIndexOutOfBounds(row: Int, rows: Int) + + /// A column index is outside the matrix bounds. + case columnIndexOutOfBounds(column: Int, columns: Int) + + /// MaxVol currently requires a tall or square input matrix. + case nonTallMatrix(rows: Int, columns: Int) + + /// A requested selection count is incompatible with the available rows. + case invalidSelectionCount(requested: Int, availableRows: Int) + + /// A selected row index is outside the input matrix bounds. + case invalidSelectedRowIndex(row: Int, availableRows: Int) + + /// A result contains the same selected row more than once. + case duplicateSelectedRow(row: Int) + + /// The result coefficient matrix does not match the selected row count. + case coefficientColumnMismatch(selectedRows: Int, coefficientColumns: Int) + + /// The convergence tolerance is not finite or is less than `1.0`. + case invalidTolerance(Double) + + /// The maximum iteration limit is negative. + case invalidIterationLimit(Int) + + /// A result reports a negative iteration count. + case invalidIterationCount(Int) + + /// The selected basis is rank-deficient. + case rankDeficient(pivot: Int) + + /// An Accelerate LAPACK routine reported an unexpected nonzero `info` value. + case lapackFailure(routine: String, info: Int) + +} + +extension MaxVolError: CustomStringConvertible { + public var description: String { + switch self { + case let .invalidDimensions(rows, columns): + "MaxVol expected positive matrix dimensions, but received rows: \(rows), columns: \(columns)." + case let .malformedMatrix(rows, columns, expectedCount, actualCount): + "MaxVol expected \(expectedCount) values for a \(rows) x \(columns) dense matrix, but received \(actualCount)." + case let .matrixIndexOutOfBounds(row, column, rows, columns): + "MaxVol matrix index is out of bounds: row \(row), column \(column), valid row range 0..<\(rows), valid column range 0..<\(columns)." + case let .rowIndexOutOfBounds(row, rows): + "MaxVol matrix row index \(row) is out of bounds for valid row range 0..<\(rows)." + case let .columnIndexOutOfBounds(column, columns): + "MaxVol matrix column index \(column) is out of bounds for valid column range 0..<\(columns)." + case let .nonTallMatrix(rows, columns): + "MaxVol requires a tall or square input matrix with rows >= columns, but received rows: \(rows), columns: \(columns)." + case let .invalidSelectionCount(requested, availableRows): + "MaxVol cannot select \(requested) rows from a matrix with \(availableRows) available rows." + case let .invalidSelectedRowIndex(row, availableRows): + "MaxVol selected row index \(row) is out of bounds for a matrix with \(availableRows) rows." + case let .duplicateSelectedRow(row): + "MaxVol selected row index \(row) appears more than once, but selected rows must be unique." + case let .coefficientColumnMismatch(selectedRows, coefficientColumns): + "MaxVol result has \(selectedRows) selected rows but \(coefficientColumns) coefficient columns." + case let .invalidTolerance(tolerance): + "MaxVol tolerance must be finite and at least 1.0, but received \(tolerance)." + case let .invalidIterationLimit(limit): + "MaxVol maximum iteration limit must be nonnegative, but received \(limit)." + case let .invalidIterationCount(iterations): + "MaxVol result cannot report a negative iteration count, but received \(iterations)." + case let .rankDeficient(pivot): + "MaxVol could not continue because the input matrix appears rank-deficient at pivot \(pivot)." + case let .lapackFailure(routine, info): + "Accelerate LAPACK routine \(routine) reported info \(info)." + } + } +} diff --git a/Sources/MaxVol/MaxVolOptions.swift b/Sources/MaxVol/MaxVolOptions.swift new file mode 100644 index 0000000..f86e72c --- /dev/null +++ b/Sources/MaxVol/MaxVolOptions.swift @@ -0,0 +1,30 @@ +/// Controls MaxVol row-replacement convergence. +public struct MaxVolOptions: Equatable, Hashable, Sendable { + /// The maximum allowed absolute coefficient before another replacement is attempted. + /// + /// Values must be finite and at least `1.0`. The default follows the + /// common practical MaxVol tolerance used to avoid unnecessary churn around + /// exact equality. + public let tolerance: Double + + /// The maximum number of row-replacement iterations. + public let maxIterations: Int + + /// Creates MaxVol options. + public init(tolerance: Double = 1.05, maxIterations: Int = 100) { + self.tolerance = tolerance + self.maxIterations = maxIterations + } + + func validated() throws -> Self { + guard tolerance.isFinite, tolerance >= 1 else { + throw MaxVolError.invalidTolerance(tolerance) + } + + guard maxIterations >= 0 else { + throw MaxVolError.invalidIterationLimit(maxIterations) + } + + return self + } +} diff --git a/Sources/MaxVol/MaxVolResult.swift b/Sources/MaxVol/MaxVolResult.swift new file mode 100644 index 0000000..1994cde --- /dev/null +++ b/Sources/MaxVol/MaxVolResult.swift @@ -0,0 +1,69 @@ +/// The row selection and expansion coefficients produced by MaxVol. +public struct MaxVolResult: Sendable { + /// The selected row indices in the input matrix. + public let selectedRows: [Int] + + /// Coefficients that reconstruct the input from the selected rows. + /// + /// For an input matrix `A`, the result is shaped so + /// `A ~= coefficients * A[selectedRows, :]`. + public let coefficients: DenseColumnMajorMatrix + + /// The number of row-replacement iterations performed after the initial basis. + public let iterations: Int + + /// Whether the coefficient matrix satisfied the configured tolerance. + public let converged: Bool + + /// Creates a validated result value. + /// + /// The selected row count must match the coefficient column count, selected + /// rows must be unique and in bounds, and the iteration count must be + /// nonnegative. + public init( + selectedRows: [Int], + coefficients: DenseColumnMajorMatrix, + iterations: Int, + converged: Bool = true + ) throws { + try Self.validate( + selectedRows: selectedRows, + coefficients: coefficients, + iterations: iterations + ) + + self.selectedRows = selectedRows + self.coefficients = coefficients + self.iterations = iterations + self.converged = converged + } + + private static func validate( + selectedRows: [Int], + coefficients: DenseColumnMajorMatrix, + iterations: Int + ) throws { + guard iterations >= 0 else { + throw MaxVolError.invalidIterationCount(iterations) + } + guard selectedRows.count == coefficients.columns else { + throw MaxVolError.coefficientColumnMismatch( + selectedRows: selectedRows.count, + coefficientColumns: coefficients.columns + ) + } + + var seenRows = Set() + for row in selectedRows { + guard (0.., + using result: MaxVolResult, + tolerance: Double = 1e-10 + ) throws { + for row in 0.., + rowMajorValues: [Double], + tolerance: Double = 1e-12 + ) throws { + let expected = try DenseColumnMajorMatrix( + rows: coefficients.rows, + columns: coefficients.columns, + rowMajorValues: rowMajorValues + ) + + for row in 0.. + ) -> Double { + coefficients.values.map(abs).max() ?? 0 + } +} diff --git a/Tests/MaxVolTests/MaxVolTests.swift b/Tests/MaxVolTests/MaxVolTests.swift deleted file mode 100644 index c35d4ac..0000000 --- a/Tests/MaxVolTests/MaxVolTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Testing -@testable import MaxVol - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing -} 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..b0afa95 --- /dev/null +++ b/scripts/repo-maintenance/lib/common.sh @@ -0,0 +1,202 @@ +#!/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_semver_prerelease_tag() { + tag_name="$1" + case "$tag_name" in + v[0-9]*.[0-9]*.[0-9]*-*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +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..a9aa4be --- /dev/null +++ b/scripts/repo-maintenance/release.sh @@ -0,0 +1,496 @@ +#!/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}" +review_comments_addressed="false" +skip_branch_cleanup="false" +dry_run="false" +remote_ci_mode="${REPO_MAINTENANCE_REMOTE_CI_MODE:-full}" + +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 + ;; + --review-comments-addressed) + review_comments_addressed="true" + shift + ;; + --remote-ci-mode) + remote_ci_mode="${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] [--review-comments-addressed] [--remote-ci-mode full|defer] [--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() { + case "$RELEASE_TAG" in + v[0-9]*.[0-9]*.[0-9]*|v[0-9]*.[0-9]*.[0-9]*-*) + ;; + *) + die "Release tag must use vX.Y.Z SemVer syntax." + ;; + esac +} + +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" + head_subject="$(git -C "$REPO_ROOT" log -1 --format=%s 2>/dev/null || true)" + + if [ "$skip_version_bump" = "true" ]; then + log "Skipping repo version bump because --skip-version-bump was requested." + return 0 + fi + + if [ "$head_subject" = "release: bump versions for $RELEASE_TAG" ]; then + log "Version bump commit for $RELEASE_TAG is already at HEAD; continuing the release resume path." + 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() { + 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." + return 0 + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would create annotated tag $RELEASE_TAG at HEAD." + return 0 + fi + + git -C "$REPO_ROOT" tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" + log "Created annotated tag $RELEASE_TAG." +} + +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" + 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 +} + +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 // ""')" + comment_count="$(gh pr view "$pr_number" --json comments,reviews --jq '([.comments[]?, (.reviews[]? | select(.state == "COMMENTED"))] | length)')" + + 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 [ "$comment_count" != "0" ] && [ "$review_comments_addressed" != "true" ]; then + gh pr view "$pr_number" --comments + die "PR #$pr_number has review or discussion comments. Address and resolve valid concerns, add out-of-scope concerns to ROADMAP.md, then rerun release.sh with --review-comments-addressed once the comment pass is intentionally complete." + 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." + return 0 + fi + + gh pr merge "$pr_number" --merge --delete-branch + log "Merged PR #$pr_number into $base_branch." +} + +fast_forward_base_branch() { + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would fast-forward local $base_branch from origin/$base_branch." + return 0 + fi + + git -C "$REPO_ROOT" fetch origin "$base_branch" + if git -C "$REPO_ROOT" switch "$base_branch" 2>/dev/null || git -C "$REPO_ROOT" checkout "$base_branch" 2>/dev/null; then + git -C "$REPO_ROOT" pull --ff-only origin "$base_branch" + log "Fast-forwarded local $base_branch." + else + die "Could not check out local $base_branch, likely because another worktree owns it. Fast-forward $base_branch from origin/$base_branch in that checkout, then rerun release.sh so the release tag is created from the reviewed base branch." + fi +} + +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 local branches already merged into $base_branch, including $release_branch_name when safe." + return 0 + fi + + git -C "$REPO_ROOT" remote prune origin + for merged_branch in $(git -C "$REPO_ROOT" for-each-ref --format='%(refname:short)' --merged "$base_branch" refs/heads); do + case "$merged_branch" in + "$base_branch") + ;; + *) + git -C "$REPO_ROOT" branch -d "$merged_branch" >/dev/null 2>&1 || warn "Could not delete local merged branch $merged_branch; it may be checked out in another worktree." + ;; + esac + done + log "Cleaned up local branches already merged into $base_branch where safe." +} + +run_standard_release() { + ensure_git_repo + ensure_gh_cli + ensure_semver_tag + ensure_remote_ci_mode + 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" + fast_forward_base_branch + create_release_tag + 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..1e6a12e --- /dev/null +++ b/scripts/repo-maintenance/release/10-preflight.sh @@ -0,0 +1,35 @@ +#!/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 + +case "${RELEASE_TAG:-}" in + v[0-9]*.[0-9]*.[0-9]*|v[0-9]*.[0-9]*.[0-9]*-*) + ;; + *) + die "Release tag must use vX.Y.Z SemVer syntax." + ;; +esac + +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..80e147b --- /dev/null +++ b/scripts/repo-maintenance/release/20-tag-release.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" + +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_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..2220e09 --- /dev/null +++ b/scripts/repo-maintenance/release/40-github-release.sh @@ -0,0 +1,35 @@ +#!/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 + warn "gh is unavailable, so the release tag was pushed without creating a GitHub release object." + exit 0 +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..66ff612 --- /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 executable `.sh` files here only when the repository needs deterministic shared-sync follow-up steps. + +## 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."