From 758ba06c7bbc4a03b5b61b48bd52739622be0064 Mon Sep 17 00:00:00 2001 From: legrab Date: Sat, 8 Aug 2026 19:36:27 +0200 Subject: [PATCH] feat: provide release-based installation --- .github/workflows/release.yml | 116 +++++++++++++++++++++++++ .gitignore | 1 + CHANGELOG.md | 11 +++ MANIFEST.txt | 16 ++-- README.md | 27 ++++-- docs/DESIGN_NOTES.md | 18 ++++ scripts/README.md | 56 ++++++++++-- scripts/build-release.sh | 108 +++++++++++++++++++++++ scripts/ci-release-test.sh | 73 ++++++++++++++++ scripts/install.ps1 | 142 ++++++++++++++++++++++++++---- scripts/install.sh | 158 ++++++++++++++++++++++++++++++++-- 11 files changed, 683 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/build-release.sh create mode 100644 scripts/ci-release-test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..adc94be --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,116 @@ +name: Release + +# Simple version tags on main are the release trigger (see +# docs/DESIGN_NOTES.md, release-based distribution). Nothing here treats +# "latest" specially: every release is an explicit, exact tag, and every +# tag that reaches this workflow is validated the same way main is, +# then packaged, then exercised end to end, before anything is published. + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: write + +jobs: + build-and-validate: + name: Build and validate package + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install validation dependency + run: python -m pip install --disable-pip-version-check pyyaml + + - name: Validate repository structure + run: python scripts/ci-validate.py + + - name: Derive version from tag + id: version + run: echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + + - name: Build release package + run: scripts/build-release.sh "${{ steps.version.outputs.version }}" + + - name: Verify the build is reproducible + run: | + set -eu + version="${{ steps.version.outputs.version }}" + mv "dist/codebase-learning-flow-$version.zip" /tmp/first-build.zip + rm -rf dist + scripts/build-release.sh "$version" + cmp /tmp/first-build.zip "dist/codebase-learning-flow-$version.zip" + echo "Two independent builds of $version produced byte-identical packages." + + - name: Exercise the packaged installation (install.sh) + run: scripts/ci-release-test.sh "dist/codebase-learning-flow-${{ steps.version.outputs.version }}.zip" + + - uses: actions/upload-artifact@v4 + with: + name: release-package + path: | + dist/codebase-learning-flow-${{ steps.version.outputs.version }}.zip + dist/checksums.txt + retention-days: 7 + + powershell-install-check: + name: Exercise the packaged installation (install.ps1) + needs: build-and-validate + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: release-package + path: dist + + - name: Install minimal profile from the local package + shell: pwsh + run: | + $version = "${{ needs.build-and-validate.outputs.version }}" + $package = "dist/codebase-learning-flow-$version.zip" + $root = Join-Path $env:RUNNER_TEMP "learning-flow-release-ci" + New-Item -ItemType Directory -Force -Path $root | Out-Null + & "$PWD/scripts/install.ps1" ` + -TargetPath $root ` + -PackageFile $package ` + -Profile minimal ` + -Mode fail ` + -SkipRootAgents + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if (-not (Test-Path "$root/learning-flow")) { + throw "Installer did not create the learning-flow component from the packaged release." + } + if (-not (Test-Path "$root/.agents/skills")) { + throw "Installer did not create .agents/skills from the packaged release." + } + + publish: + name: Publish GitHub Release + needs: [build-and-validate, powershell-install-check] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: release-package + path: dist + + - name: Publish release with validated assets + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.build-and-validate.outputs.version }} + generate_release_notes: true + files: | + dist/codebase-learning-flow-${{ needs.build-and-validate.outputs.version }}.zip + dist/checksums.txt diff --git a/.gitignore b/.gitignore index 498e909..d940b98 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ Desktop.ini # Locally generated distribution archives /INITIALIZE_LEARNING_FLOW_*.zip /codebase-learning-flow*.zip +/dist/ # Python script cache /scripts/__pycache__ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da911f..6ff5e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Added +- Packaged, checksum-verified release distribution: `--release`/`-Release TAG` in `install.sh`/`install.ps1` downloads the artifact and `checksums.txt` published against an exact tag, verifies the SHA-256 checksum before extracting anything, and cross-checks the package's own `VERSION` file against the requested tag. `--release latest` is rejected; an exact tag is required. +- `scripts/build-release.sh`, which builds the release package directly from `MANIFEST.txt` (the existing package manifest), normalizing file timestamps so two independent builds of the same tree at the same version produce a byte-identical archive. +- `scripts/ci-release-test.sh`, which installs a built package end to end (minimal, full+regulatory, update mode, fail-mode refusal, adoption-resource presence) without touching the network. +- `.github/workflows/release.yml`: on a `v*.*.*` tag push, validates repository structure, builds the release package, verifies build reproducibility, exercises the packaged installation on both `install.sh` and `install.ps1`, and only then publishes the GitHub Release with the package and `checksums.txt` attached. +- A closing `Version:`/`Source:` summary in both installers, distinguishing a packaged release (`packaged release (checksum verified)`) from a development checkout (`development checkout (mutable unless ref is a commit or tag)`). - A shared `agentic-flow/EDUCATION.md` constitution used by general and repository learning. - Human-readable Mermaid maps, highlight blocks, and progressive disclosure across core README files. - Explicit resilience, responsible AI leverage, educational judgment, and domain ownership lenses. @@ -16,6 +21,12 @@ - Minimal and full maps and takeaways now retain reusable ownership, resilience, AI fallback, and control knowledge. - Full-profile skills now validate machine-generated work and surface operational or human control boundaries in proportion to risk. - Human-facing documentation now explains the framework before agentic implementation detail. +- README and `scripts/README.md` installation guidance now documents packaged-release installation as implemented behavior rather than a planned path. + +### Fixed + +- `MANIFEST.txt` was missing `docs/ARCHITECTURE.md` and `docs/AGENTIC_WORKFLOW_SANITY.md`, both of which are linked from README and `docs/README.md`; both are now declared with correct sizes. +- `install.ps1` reported a hardcoded, unmaintained `$InstallerVersion` (stuck at `0.8.0`) in a self-refresh log line; removed in favor of the new commit/tag-based version reporting, which cannot go stale. ## 0.8.0 diff --git a/MANIFEST.txt b/MANIFEST.txt index 8920924..99d22a6 100644 --- a/MANIFEST.txt +++ b/MANIFEST.txt @@ -1,8 +1,10 @@ .gitattributes 40 -.gitignore 317 +.gitignore 324 AGENTS.md 3172 -CHANGELOG.md 13500 -docs/DESIGN_NOTES.md 23730 +CHANGELOG.md 15425 +docs/AGENTIC_WORKFLOW_SANITY.md 7795 +docs/ARCHITECTURE.md 6009 +docs/DESIGN_NOTES.md 27644 docs/EDUCATION_MODEL.md 5452 docs/INITIALIZE_LEARNING_FLOW.md 10880 docs/README.md 4185 @@ -14,7 +16,7 @@ docs/references/REFERENCE_REVIEW_LEARNING_FLOW_ADJUSTMENT.md 9505 docs/references/REFERENCE_REVIEW_LITT.md 2069 docs/references/REFERENCE_REVIEW_POCOK.md 2155 LICENSE 2213 -README.md 16282 +README.md 17154 sample/common/.agents/skills/agentic-workflow/SKILL.md 4185 sample/common/.agents/skills/learn-anything/agents/openai.yaml 246 sample/common/.agents/skills/learn-anything/SKILL.md 3362 @@ -95,9 +97,9 @@ sample/README.md 2997 sample/root/AGENTS.md 1339 sample/root/AGENTS.pointer.md 296 scripts/install.bat 1477 -scripts/install.ps1 35102 -scripts/install.sh 29472 -scripts/README.md 4900 +scripts/install.ps1 40236 +scripts/install.sh 34910 +scripts/README.md 6656 skill-evals/agentic-cases.yaml 7109 skill-evals/conversation-cases.yaml 3653 skill-evals/full-cases.yaml 4248 diff --git a/README.md b/README.md index 7bf421c..db15e9d 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,24 @@ The framework is intentionally tested against representative low-risk, learning, ### Preferred: packaged release For team and enterprise use, install a reviewed, versioned release rather than -executing a mutable checkout from `main`. Release installation will be the -preferred distribution path once packaged releases are published. +executing a mutable checkout from `main`. `--release`/`-Release` downloads the +packaged artifact published against an exact tag, verifies its checksum +before extracting anything, and refuses `latest`: pin the version the team +actually reviewed. -Pin the exact release version used by the team and retain the version in the -installation record. +```sh +curl -fsSL https://raw.githubusercontent.com/legrab/codebase-learning-flow/main/scripts/install.sh -o install.sh +sh install.sh --release v0.9.0 --profile minimal +``` + +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/legrab/codebase-learning-flow/main/scripts/install.ps1))) -Release v0.9.0 -Profile Minimal +``` + +The installer's closing summary states the resolved `Version:` and `Source:` +so the pinned version is easy to record alongside the installation. See +[`scripts/README.md`](scripts/README.md#installing-a-packaged-release) for +the full flag reference and what checksum verification actually checks. ### Development checkout @@ -112,7 +125,11 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr https://raw.githubus ./scripts/install.sh --mode update ``` -Remote piping executes the referenced revision. Pin a release tag or commit for a team installation. +Remote piping executes whatever `main` currently resolves to. For a team or +enterprise installation, use `--release`/`-Release` with an exact tag (see +"Preferred: packaged release" above) rather than pinning a checkout commit: +the release path adds checksum verification and is what CI actually +validates before publishing. diff --git a/docs/DESIGN_NOTES.md b/docs/DESIGN_NOTES.md index f3117a8..d7d84ba 100644 --- a/docs/DESIGN_NOTES.md +++ b/docs/DESIGN_NOTES.md @@ -223,6 +223,23 @@ Minimal-to-full update is supported. Full-to-minimal update is rejected because Extensions (currently only `regulatory`) use the same three modes along a dimension orthogonal to profile: they track their own managed-file and managed-skill manifests under distinct marker names so they never collide with the profile's own markers, and adding or removing one never touches the other's files. +## Release-based distribution + +A checkout install (`--ref`/`-Ref`, default `main`) and a packaged-release install (`--release`/`-Release`) are two distinct trust boundaries, not two code paths for the same thing: + +- **Checkout** downloads GitHub's own source-archive snapshot of an arbitrary ref. It is the mutable, development-oriented path: convenient for trying the framework or tracking `main`, but nothing about it asserts that the content was reviewed as a unit. +- **Packaged release** downloads a purpose-built artifact published against an exact, immutable tag, with a checksum the installer verifies before extracting anything. `scripts/build-release.sh` builds this artifact directly from `MANIFEST.txt`, so the package's contents and the package's own manifest never drift apart: `MANIFEST.txt` is the single declared list of "what ships," used both for CI's size/drift check and for the release build. + +Design decisions specific to this boundary: + +- **No `latest` shortcut.** Both installers reject `--release latest` outright rather than resolving and warning. An enterprise install that claims to be version-pinned should not have a silent path to "whatever the newest tag happens to be today." Pinning is enforced by the absence of the feature, not by a warning someone can miss. +- **The installer reports its trust boundary, not just its ref.** The closing summary always states `Version:` and `Source:` distinctly for a checkout (`development checkout`) versus a packaged release (`packaged release (checksum verified)`), so a person looking at installer output (or CI logs) can tell which boundary they got without reading the flags that produced it. +- **The release path does not require `git`.** Only the self-refresh stage (pinning the installer script itself to the release commit) touches `resolve_remote_commit`; the payload step downloads the release asset and its checksum over HTTP(S) directly, so a minimal environment with just `curl`/`wget`, `unzip`, and a SHA-256 tool can install a pinned release. +- **The package is a curated subset, not the whole repository.** `MANIFEST.txt` already excludes CI/workflow files and maintainer-only scripts (`ci-validate.py`, `check_manifest.py`, `manifest-update.py`, `ci-install-test.sh`, and now `build-release.sh`/`ci-release-test.sh`) from "the package" -- consistent with the pre-existing convention that repository infrastructure lives outside the package manifest. The release artifact ships exactly what an installing repository needs plus the documentation required to understand and adopt it. +- **Reproducibility is enforced, not assumed.** `scripts/build-release.sh` normalizes staged file mtimes before zipping specifically so that two independent builds of the same tree at the same version produce a byte-identical archive; CI builds twice and diffs them before anything is exercised or published. +- **CI validates the artifact it is about to publish, not just the source tree.** The release workflow builds the package, confirms reproducibility, then runs the installer against the built package itself (minimal, full+regulatory, update-mode, and fail-mode-refusal, on both the POSIX and PowerShell installers) before a GitHub Release is created. A release that fails any of these checks is never published. +- **A hidden `--package-file`/`-PackageFile` flag exists solely for this CI loop.** It installs directly from a local archive, bypassing both the network and self-refresh, which is what lets CI exercise a release package before that package has actually been published anywhere. It is intentionally undocumented in `--help`/user-facing docs: it is a testing seam, not a supported installation method. + ## Deliberately rejected - mandatory configuration before routine work; @@ -254,3 +271,4 @@ Extensions (currently only `regulatory`) use the same three modes along a dimens 11. The Markdown fallback works without skill support. 12. No workflow requires contributor identity unless the user explicitly wants personal tracking. 13. An installed extension never changes what a profile means, and adding or removing one never touches unrelated framework or repository content. +14. A checkout install and a packaged-release install are never ambiguous about which one ran: the installer states its version and trust boundary, and there is no path that silently resolves an unpinned "latest" release. diff --git a/scripts/README.md b/scripts/README.md index c362fe3..14d6190 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,13 +6,16 @@ These scripts perform **complete installation**. They are intentionally separate from the guided adoption process under `adoption/`, which is for repositories that already have their own agentic delivery layer. -For team and enterprise use, the preferred future distribution path is a pinned -packaged release. Checkout-based installers remain useful for framework -development and experimentation. +For team and enterprise use, the preferred distribution path is a pinned, +checksum-verified packaged release (`--release`/`-Release`). Checkout-based +installers (`--ref`/`-Ref`, defaulting to `main`) remain available and are the +right choice for framework development and experimentation, but they resolve +a mutable source snapshot with no checksum, so treat them as a development +path rather than a production one. ```mermaid flowchart LR - D[Download pinned source] --> P[Select profile] + D[Resolve source: checkout ref or pinned release] --> P[Select profile] P --> C[Install common agentic flow] C --> L[Install learning profile] L --> Ext[Install or remove regulatory extension] @@ -21,6 +24,49 @@ flowchart LR X --> R[Integrate or preserve root AGENTS] ``` +## Installing a packaged release + +```text +--release TAG +-Release TAG +``` + +```text +sh install.sh --release v0.9.0 --profile minimal +``` + +```powershell +.\install.ps1 -Release v0.9.0 -Profile Minimal +``` + +`--release`/`-Release` downloads the packaged artifact and `checksums.txt` +published against that exact tag on the repository's Releases page, verifies +the SHA-256 checksum before extracting anything, and cross-checks the +package's own `VERSION` file against the requested tag. `--ref`/`-Ref` and +`--release`/`-Release` are mutually exclusive. `latest` is not accepted as a +release value: look up the tag you want on the Releases page and pass it +explicitly. This is deliberate, not an oversight -- see "Release-based +distribution" in `docs/DESIGN_NOTES.md`. + +Every install prints which trust boundary it used: + +```text +Codebase Learning Flow +Version: v0.9.0 +Source: packaged release (checksum verified) +``` + +```text +Codebase Learning Flow +Version: 4f2ab61 (ref: main) +Source: development checkout (mutable unless ref is a commit or tag) +``` + +Release packages are built by `scripts/build-release.sh` from `MANIFEST.txt` +and validated end to end (`scripts/ci-release-test.sh`, on both installers) +by `.github/workflows/release.yml` before anything is published. A release +never ships something CI has not already installed and exercised. + ## Installed components 1. common `agentic-flow/`; @@ -102,6 +148,6 @@ The installer never replaces an existing root file wholesale. - Old contributor placeholders retired by a managed manifest can be removed during update. - Contributor-authored legacy learning state is never deleted automatically. Copy it into `.local/`, verify it, then remove the tracked source explicitly. - Repeated local workspace initialization is idempotent. -- Team installations should pin a tag or commit rather than relying on a moving branch. +- Team installations should use `--release`/`-Release` with an exact tag rather than relying on a moving branch. A `--ref`/`-Ref` commit SHA is pinned too, but skips checksum verification and the packaged-release documentation-inclusion guarantees. diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100644 index 0000000..daa027b --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env sh +# Build the packaged, versioned release artifact described in +# docs/DESIGN_NOTES.md's release-based distribution section. +# +# The package contents are exactly the files declared in MANIFEST.txt: that +# file is already the package manifest (see scripts/ci-validate.py), so the +# build has one source of truth instead of a second, drifting file list. +# +# Usage: scripts/build-release.sh vX.Y.Z [output-dir] +# +# Produces: +# /codebase-learning-flow-.zip +# /checksums.txt +# +# The zip's single top-level directory is codebase-learning-flow-/, +# matching the shape of GitHub's own source-archive downloads so the +# installer's existing extraction logic works unchanged for both a +# checkout-based install and a packaged-release install. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +VERSION="${1:-}" +OUTPUT_DIR="${2:-$ROOT/dist}" + +if [ -z "$VERSION" ]; then + echo "Usage: $0 vX.Y.Z [output-dir]" >&2 + exit 2 +fi + +case "$VERSION" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) + echo "Version must look like v0.9.0 (got: $VERSION)." >&2 + exit 2 + ;; +esac + +require_checksum_tool() { + if command -v sha256sum >/dev/null 2>&1; then + CHECKSUM_CMD="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + CHECKSUM_CMD="shasum" + else + echo "Building a release requires sha256sum or shasum." >&2 + exit 1 + fi +} + +sha256_of() { + file="$1" + case "$CHECKSUM_CMD" in + sha256sum) sha256sum "$file" | awk '{print $1}' ;; + shasum) shasum -a 256 "$file" | awk '{print $1}' ;; + esac +} + +command -v zip >/dev/null 2>&1 || { echo "Building a release requires zip." >&2; exit 1; } +require_checksum_tool + +echo "Validating repository structure before packaging" +python3 "$SCRIPT_DIR/ci-validate.py" + +PACKAGE_NAME="codebase-learning-flow-$VERSION" +STAGE_ROOT="$(mktemp -d 2>/dev/null || mktemp -d -t codebase-learning-flow-release)" +trap 'rm -rf "$STAGE_ROOT"' EXIT INT HUP TERM +STAGE_DIR="$STAGE_ROOT/$PACKAGE_NAME" +mkdir -p "$STAGE_DIR" + +echo "Staging package contents from MANIFEST.txt" +# Sorted, deterministic order: two builds from the same tree at the same +# version must produce byte-identical zips (verified by CI, not asserted +# here). +sort "$ROOT/MANIFEST.txt" | while IFS="$(printf '\t')" read -r relative _size; do + [ -n "$relative" ] || continue + source_file="$ROOT/$relative" + [ -f "$source_file" ] || { echo "MANIFEST entry missing on disk: $relative" >&2; exit 1; } + target_file="$STAGE_DIR/$relative" + mkdir -p "$(dirname "$target_file")" + cp "$source_file" "$target_file" +done + +printf '%s\n' "$VERSION" > "$STAGE_DIR/VERSION" + +# Normalize mtimes so two builds of the same tree at the same version +# produce a byte-identical zip regardless of when each build ran (zip +# embeds per-entry timestamps; the source files' own mtimes otherwise leak +# build-wall-clock-time into the archive). +find "$STAGE_DIR" -exec touch -t 202001010000 {} + + +mkdir -p "$OUTPUT_DIR" +ARCHIVE_PATH="$OUTPUT_DIR/$PACKAGE_NAME.zip" +rm -f "$ARCHIVE_PATH" + +echo "Building $ARCHIVE_PATH" +( + cd "$STAGE_ROOT" + # -X drops extra file attributes (uid/gid/timestamps beyond what zip + # always stores) so two builds of the same tree produce the same bytes. + find "$PACKAGE_NAME" -type f | LC_ALL=C sort | zip -X -q "$ARCHIVE_PATH" -@ +) + +CHECKSUM="$(sha256_of "$ARCHIVE_PATH")" +printf '%s %s\n' "$CHECKSUM" "$PACKAGE_NAME.zip" > "$OUTPUT_DIR/checksums.txt" + +echo "Built $PACKAGE_NAME.zip" +echo "Checksum: $CHECKSUM" diff --git a/scripts/ci-release-test.sh b/scripts/ci-release-test.sh new file mode 100644 index 0000000..65b9495 --- /dev/null +++ b/scripts/ci-release-test.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env sh +# Exercise a built release package the way an end user would, without +# touching the network. Intended to run in CI, between "build the package" +# and "publish the GitHub Release", so a broken package never ships. +# +# Usage: scripts/ci-release-test.sh path/to/codebase-learning-flow-vX.Y.Z.zip +set -eu + +PACKAGE_PATH="${1:-}" +if [ -z "$PACKAGE_PATH" ] || [ ! -f "$PACKAGE_PATH" ]; then + echo "Usage: $0 path/to/codebase-learning-flow-vX.Y.Z.zip" >&2 + exit 2 +fi +# Resolve to an absolute path: run_install below cd's into per-scenario +# target directories, so a relative path would stop resolving after the +# first install. +PACKAGE_PATH="$(cd "$(dirname "$PACKAGE_PATH")" && pwd)/$(basename "$PACKAGE_PATH")" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/install.sh" + +fail() { + echo "ERROR: $1" >&2 + exit 1 +} + +# --- Adoption resources are present in the package itself ----------------- +INSPECT_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t codebase-learning-flow-inspect)" +unzip -q "$PACKAGE_PATH" -d "$INSPECT_DIR" +PACKAGE_ROOT="$(find "$INSPECT_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)" +[ -f "$PACKAGE_ROOT/adoption/ADOPT.md" ] || fail "Package is missing adoption/ADOPT.md" +[ -f "$PACKAGE_ROOT/adoption/README.md" ] || fail "Package is missing adoption/README.md" +[ -f "$PACKAGE_ROOT/VERSION" ] || fail "Package is missing a VERSION file" +rm -rf "$INSPECT_DIR" +echo "OK: adoption resources and VERSION present in package" + +run_install() { + label="$1" + target="$2" + shift 2 + mkdir -p "$target" + ( + cd "$target" + sh "$INSTALL_SH" --package-file "$PACKAGE_PATH" "$@" + ) || fail "Install failed: $label" +} + +WORK_ROOT="$(mktemp -d 2>/dev/null || mktemp -d -t codebase-learning-flow-ci-release)" +trap 'rm -rf "$WORK_ROOT"' EXIT INT HUP TERM + +# --- Exercise the major profiles/extensions -------------------------------- +run_install "minimal, no extension" "$WORK_ROOT/minimal" --profile minimal --extension none +[ -d "$WORK_ROOT/minimal/.agents/skills" ] || fail "minimal install has no .agents/skills" +[ -d "$WORK_ROOT/minimal/agentic-flow" ] || fail "minimal install has no agentic-flow/" +[ -d "$WORK_ROOT/minimal/learning-flow" ] || fail "minimal install has no learning-flow/" +echo "OK: minimal profile installs" + +run_install "full, regulatory extension" "$WORK_ROOT/full-regulatory" --profile full --extension regulatory +[ -d "$WORK_ROOT/full-regulatory/.agents/skills/regulatory-knowledge" ] || fail "regulatory extension did not install regulatory-knowledge skill" +echo "OK: full profile with regulatory extension installs" + +# --- Update behavior: a second install over an existing one is non-destructive +run_install "update over existing minimal install" "$WORK_ROOT/minimal" --mode update +[ -d "$WORK_ROOT/minimal/.agents/skills" ] || fail "update mode removed .agents/skills" +echo "OK: update mode preserves and refreshes an existing installation" + +# --- Fail mode refuses to clobber an existing installation ----------------- +if (cd "$WORK_ROOT/minimal" && sh "$INSTALL_SH" --package-file "$PACKAGE_PATH" --mode fail) 2>/dev/null; then + fail "install.sh --mode fail unexpectedly succeeded over an existing installation" +fi +echo "OK: fail mode refuses to overwrite an existing installation" + +echo "All packaged-release checks passed for $PACKAGE_PATH" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7c6dbc7..8775689 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -3,6 +3,14 @@ param( [string]$TargetPath = (Get-Location).Path, [string]$Repository = "legrab/codebase-learning-flow", [string]$Ref = "main", + # Exact published release tag (e.g. v0.9.0). Preferred for team/enterprise + # installs: downloads the packaged, checksum-verified release artifact + # instead of a mutable source snapshot. "latest" is deliberately not + # supported; pin an exact tag. Mutually exclusive with -Ref. + [string]$Release = "", + # Internal/CI hook: install directly from an already-built local release + # package without touching the network. Not part of the public contract. + [string]$PackageFile = "", [ValidateSet("Auto", "Minimal", "Full")] [string]$Profile = "Auto", [ValidateSet("Auto", "None", "Regulatory")] @@ -18,12 +26,40 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$InstallerVersion = "0.8.0" + +if ($PSBoundParameters.ContainsKey('Ref') -and $PSBoundParameters.ContainsKey('Release')) { + throw "-Ref and -Release are mutually exclusive. Use -Release for a pinned packaged release, or -Ref for a development checkout." +} +if ($Release -match "^(?i)latest$") { + throw "-Release latest is not supported. Pin an exact published tag, e.g. -Release v0.9.0. Look up the current tags on the repository's Releases page." +} +if (-not [string]::IsNullOrWhiteSpace($Release)) { + $Ref = $Release +} +if (-not [string]::IsNullOrWhiteSpace($PackageFile)) { + $SkipSelfRefresh = $true +} function Write-Step([string]$Message) { Write-Host "[learning-flow] $Message" } +function Get-Sha256([string]$Path) { + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Confirm-Checksum([string]$Path, [string]$ChecksumsPath, [string]$AssetName) { + $line = Get-Content -LiteralPath $ChecksumsPath | Where-Object { $_ -match "\s\*?$([regex]::Escape($AssetName))$" } | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($line)) { + throw "No checksum entry for $AssetName in checksums.txt." + } + $expected = ($line -split "\s+")[0].ToLowerInvariant() + $actual = Get-Sha256 -Path $Path + if ($expected -ne $actual) { + throw "Checksum mismatch for $AssetName`: expected $expected, got $actual." + } +} + function Initialize-LocalLearningWorkspace([string]$TargetRoot, [string]$HistoryTemplate) { if (-not (Test-Path -LiteralPath $HistoryTemplate -PathType Leaf)) { throw "Local learning-history template is missing: $HistoryTemplate" @@ -480,19 +516,41 @@ if (-not $SkipSelfRefresh) { $url = "https://raw.githubusercontent.com/$Repository/$resolvedCommit/scripts/install.ps1?nocache=$nonce" try { New-Item -ItemType Directory -Path $bootstrapRoot -Force | Out-Null - Write-Step "Refreshing installer v$InstallerVersion from commit $resolvedCommit" + Write-Step "Refreshing installer from commit $resolvedCommit" Invoke-WebRequest -Uri $url -OutFile $latestInstaller -UseBasicParsing -Headers $headers - & $latestInstaller ` - -TargetPath $TargetPath ` - -Repository $Repository ` - -Ref $resolvedCommit ` - -Profile $Profile ` - -Extension $Extension ` - -Mode $Mode ` - -RootAgents $RootAgents ` - -SkipRootAgents:$($SkipRootAgents.IsPresent) ` - -SkipSkills:$($SkipSkills.IsPresent) ` - -SkipSelfRefresh + # When -Release is active, $Ref was already set equal to it above, and + # the child recomputes its own resolved commit for pinning purposes. + # Passing both -Ref and -Release here would make the re-invocation + # look like it received both, which the child's own mutual-exclusion + # check would reject. + if (-not [string]::IsNullOrWhiteSpace($Release)) { + & $latestInstaller ` + -TargetPath $TargetPath ` + -Repository $Repository ` + -Release $Release ` + -PackageFile $PackageFile ` + -Profile $Profile ` + -Extension $Extension ` + -Mode $Mode ` + -RootAgents $RootAgents ` + -SkipRootAgents:$($SkipRootAgents.IsPresent) ` + -SkipSkills:$($SkipSkills.IsPresent) ` + -SkipSelfRefresh + } + else { + & $latestInstaller ` + -TargetPath $TargetPath ` + -Repository $Repository ` + -Ref $resolvedCommit ` + -PackageFile $PackageFile ` + -Profile $Profile ` + -Extension $Extension ` + -Mode $Mode ` + -RootAgents $RootAgents ` + -SkipRootAgents:$($SkipRootAgents.IsPresent) ` + -SkipSkills:$($SkipSkills.IsPresent) ` + -SkipSelfRefresh + } return } finally { @@ -550,15 +608,38 @@ if ($Mode -eq "Update" -and -not (Test-Path -LiteralPath $targetLearning -PathTy } $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("codebase-learning-flow-" + [Guid]::NewGuid().ToString("N")) -$archivePath = Join-Path $tempRoot "source.zip" $extractPath = Join-Path $tempRoot "extract" $nonce = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() -$archiveUrl = "https://github.com/$Repository/archive/$resolvedCommit.zip?nocache=$nonce" try { New-Item -ItemType Directory -Path $extractPath -Force | Out-Null - Write-Step "Downloading $Repository at commit $resolvedCommit" - Invoke-WebRequest -Uri $archiveUrl -OutFile $archivePath -UseBasicParsing -Headers $headers + + if (-not [string]::IsNullOrWhiteSpace($PackageFile)) { + if (-not (Test-Path -LiteralPath $PackageFile -PathType Leaf)) { + throw "Package file not found: $PackageFile" + } + $archivePath = $PackageFile + Write-Step "Installing from local package $PackageFile" + } + elseif (-not [string]::IsNullOrWhiteSpace($Release)) { + $packageName = "codebase-learning-flow-$Release.zip" + $assetBase = "https://github.com/$Repository/releases/download/$Release" + $archivePath = Join-Path $tempRoot $packageName + $checksumsPath = Join-Path $tempRoot "checksums.txt" + + Write-Step "Downloading packaged release $Release of $Repository" + Invoke-WebRequest -Uri "$assetBase/checksums.txt?nocache=$nonce" -OutFile $checksumsPath -UseBasicParsing -Headers $headers + Invoke-WebRequest -Uri "$assetBase/$packageName`?nocache=$nonce" -OutFile $archivePath -UseBasicParsing -Headers $headers + Confirm-Checksum -Path $archivePath -ChecksumsPath $checksumsPath -AssetName $packageName + Write-Step "Checksum verified for $packageName" + } + else { + $archivePath = Join-Path $tempRoot "source.zip" + $archiveUrl = "https://github.com/$Repository/archive/$resolvedCommit.zip?nocache=$nonce" + Write-Step "Downloading $Repository at commit $resolvedCommit" + Invoke-WebRequest -Uri $archiveUrl -OutFile $archivePath -UseBasicParsing -Headers $headers + } + Write-Step "Extracting template" Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force @@ -566,6 +647,17 @@ try { if ($null -eq $archiveRootItem) { throw "The downloaded archive did not contain a repository directory." } $archiveRoot = $archiveRootItem.FullName + $versionFile = Join-Path $archiveRoot "VERSION" + if (-not [string]::IsNullOrWhiteSpace($Release) -and (Test-Path -LiteralPath $versionFile -PathType Leaf)) { + $packageVersion = (Get-Content -LiteralPath $versionFile -Raw).Trim() + if ($packageVersion -ne $Release) { + throw "Package VERSION ($packageVersion) does not match requested release ($Release)." + } + } + elseif ([string]::IsNullOrWhiteSpace($Release) -and -not [string]::IsNullOrWhiteSpace($PackageFile) -and (Test-Path -LiteralPath $versionFile -PathType Leaf)) { + $Release = (Get-Content -LiteralPath $versionFile -Raw).Trim() + } + $sourceCommon = Join-Path $archiveRoot "sample/common" $sourceAgentic = Join-Path $sourceCommon "agentic-flow" $sourceCommonSkills = Join-Path $sourceCommon ".agents/skills" @@ -716,6 +808,22 @@ try { Set-RootIntegrationState -SettingsPath (Join-Path $targetAgentic "SETTINGS.md") -ResolvedMode $resolvedRootAgents + Write-Host "" + Write-Host "Codebase Learning Flow" + if (-not [string]::IsNullOrWhiteSpace($Release)) { + Write-Host "Version: $Release" + if (-not [string]::IsNullOrWhiteSpace($PackageFile)) { + Write-Host "Source: packaged release (local package file, unverified)" + } + else { + Write-Host "Source: packaged release (checksum verified)" + } + } + else { + Write-Host "Version: $resolvedCommit (ref: $Ref)" + Write-Host "Source: development checkout (mutable unless ref is a commit or tag)" + } + Write-Step "Installation complete: profile=$selectedProfile extension=$selectedExtension mode=$($Mode.ToLowerInvariant()) root-agents=$($resolvedRootAgents.ToLowerInvariant())" Write-Host "" Write-Host "Suggested first instruction:" diff --git a/scripts/install.sh b/scripts/install.sh index bb852f8..d2007cd 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,6 +4,8 @@ set -eu TARGET_PATH="$(pwd)" REPOSITORY="${CODEBASE_LEARNING_FLOW_REPOSITORY:-legrab/codebase-learning-flow}" REF="${CODEBASE_LEARNING_FLOW_REF:-main}" +RELEASE_TAG="" +PACKAGE_FILE="" MODE="fail" PROFILE="auto" EXTENSION="auto" @@ -18,7 +20,10 @@ Usage: install.sh [options] Options: --target PATH Target repository directory --repository OWNER/REPO Public template repository - --ref REF Branch, tag, or commit reference + --ref REF Branch, tag, or commit reference (development checkout path) + --release TAG Exact published release tag (e.g. v0.9.0); preferred for team/enterprise installs. + Downloads the packaged, checksum-verified release artifact instead of a mutable + source snapshot. "latest" is deliberately not supported: pin an exact tag. --profile auto|minimal|full Learning profile; auto keeps an existing profile and defaults new installs to minimal --extension auto|none|regulatory Additive installation dimension; auto keeps an existing extension and defaults new installs to none --mode fail|merge|update|replace Existing-framework behavior @@ -26,6 +31,8 @@ Options: --skip-root-agents Alias for --root-agents skip --skip-skills Do not install or update .agents/skills -h, --help Show this help + +--ref and --release are mutually exclusive. EOF } @@ -105,6 +112,46 @@ download_file() { fi } +require_checksum_tool() { + if command -v sha256sum >/dev/null 2>&1; then + CHECKSUM_CMD="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + CHECKSUM_CMD="shasum" + elif command -v openssl >/dev/null 2>&1; then + CHECKSUM_CMD="openssl" + else + echo "Release installation requires sha256sum, shasum, or openssl." >&2 + exit 1 + fi +} + +sha256_of() { + file="$1" + case "$CHECKSUM_CMD" in + sha256sum) sha256sum "$file" | awk '{print $1}' ;; + shasum) shasum -a 256 "$file" | awk '{print $1}' ;; + openssl) openssl dgst -sha256 "$file" | awk '{print $NF}' ;; + esac +} + +verify_checksum() { + file="$1" + checksums_file="$2" + asset_name="$3" + + expected="$(awk -v name="$asset_name" '$2 == name || $2 == "*" name {print $1; exit}' "$checksums_file")" + if [ -z "$expected" ]; then + echo "No checksum entry for $asset_name in checksums.txt." >&2 + exit 1 + fi + + actual="$(sha256_of "$file")" + if [ "$(printf '%s' "$expected" | tr 'A-F' 'a-f')" != "$(printf '%s' "$actual" | tr 'A-F' 'a-f')" ]; then + echo "Checksum mismatch for $asset_name: expected $expected, got $actual." >&2 + exit 1 + fi +} + resolve_remote_commit() { repository_name="$1" requested_ref="$2" @@ -254,6 +301,8 @@ set_root_integration_state() { } parse_bootstrap_source() { + user_set_ref="false" + user_set_release="false" while [ "$#" -gt 0 ]; do case "$1" in --repository) @@ -264,6 +313,19 @@ parse_bootstrap_source() { --ref) [ "$#" -ge 2 ] || { echo "--ref requires a value." >&2; exit 2; } REF="$2" + user_set_ref="true" + shift 2 + ;; + --release) + [ "$#" -ge 2 ] || { echo "--release requires a value." >&2; exit 2; } + RELEASE_TAG="$2" + user_set_release="true" + shift 2 + ;; + --package-file) + [ "$#" -ge 2 ] || { echo "--package-file requires a value." >&2; exit 2; } + PACKAGE_FILE="$2" + CODEBASE_LEARNING_FLOW_SKIP_SELF_REFRESH=1 shift 2 ;; -h|--help) @@ -273,6 +335,22 @@ parse_bootstrap_source() { *) shift ;; esac done + + if [ "$user_set_ref" = "true" ] && [ "$user_set_release" = "true" ]; then + echo "--ref and --release are mutually exclusive. Use --release for a pinned packaged release, or --ref for a development checkout." >&2 + exit 2 + fi + + case "$RELEASE_TAG" in + [Ll][Aa][Tt][Ee][Ss][Tt]) + echo "--release latest is not supported. Pin an exact published tag, e.g. --release v0.9.0. Look up the current tags on the repository's Releases page." >&2 + exit 2 + ;; + esac + + if [ -n "$RELEASE_TAG" ]; then + REF="$RELEASE_TAG" + fi } read_profile_file() { @@ -489,7 +567,7 @@ EOF } parse_bootstrap_source "$@" -require_download_tool +[ -n "$PACKAGE_FILE" ] || require_download_tool case "$REPOSITORY" in __GITHUB_OWNER__/*) @@ -512,8 +590,16 @@ if [ "${CODEBASE_LEARNING_FLOW_SKIP_SELF_REFRESH:-0}" != "1" ]; then download_file "$bootstrap_url" "$bootstrap_script" set +e - CODEBASE_LEARNING_FLOW_SKIP_SELF_REFRESH=1 \ - sh "$bootstrap_script" "$@" --repository "$REPOSITORY" --ref "$resolved_bootstrap_commit" + if [ -n "$RELEASE_TAG" ]; then + # "$@" already carries the original --release flag; forwarding --ref + # here too would make the re-exec look like it received both, which + # the child's own mutual-exclusion check would reject. + CODEBASE_LEARNING_FLOW_SKIP_SELF_REFRESH=1 \ + sh "$bootstrap_script" "$@" --repository "$REPOSITORY" + else + CODEBASE_LEARNING_FLOW_SKIP_SELF_REFRESH=1 \ + sh "$bootstrap_script" "$@" --repository "$REPOSITORY" --ref "$resolved_bootstrap_commit" + fi status=$? set -e @@ -539,6 +625,16 @@ while [ "$#" -gt 0 ]; do REF="$2" shift 2 ;; + --release) + require_value "$1" "$#" + RELEASE_TAG="$2" + shift 2 + ;; + --package-file) + require_value "$1" "$#" + PACKAGE_FILE="$2" + shift 2 + ;; --profile) require_value "$1" "$#" PROFILE="$(printf '%s' "$2" | tr 'A-Z' 'a-z')" @@ -639,23 +735,54 @@ if [ "$MODE" = "update" ] && [ ! -d "$TARGET_LEARNING" ]; then exit 1 fi -RESOLVED_COMMIT="$(resolve_remote_commit "$REPOSITORY" "$REF")" TEMP_ROOT="$(mktemp -d 2>/dev/null || mktemp -d -t codebase-learning-flow)" cleanup() { rm -rf "$TEMP_ROOT"; } trap cleanup EXIT HUP INT TERM -ARCHIVE_PATH="$TEMP_ROOT/source.zip" EXTRACT_PATH="$TEMP_ROOT/extract" NONCE="$(date +%s)" -ARCHIVE_URL="https://github.com/$REPOSITORY/archive/$RESOLVED_COMMIT.zip?nocache=$NONCE" mkdir -p "$EXTRACT_PATH" -log "Downloading $REPOSITORY at commit $RESOLVED_COMMIT" -download_file "$ARCHIVE_URL" "$ARCHIVE_PATH" +if [ -n "$PACKAGE_FILE" ]; then + # Internal/CI hook: install directly from an already-built local release + # package without touching the network. Never advertised in --help. + [ -f "$PACKAGE_FILE" ] || { echo "Package file not found: $PACKAGE_FILE" >&2; exit 1; } + ARCHIVE_PATH="$PACKAGE_FILE" + log "Installing from local package $PACKAGE_FILE" +elif [ -n "$RELEASE_TAG" ]; then + require_checksum_tool + PACKAGE_NAME="codebase-learning-flow-$RELEASE_TAG.zip" + ASSET_BASE="https://github.com/$REPOSITORY/releases/download/$RELEASE_TAG" + ARCHIVE_PATH="$TEMP_ROOT/$PACKAGE_NAME" + CHECKSUMS_PATH="$TEMP_ROOT/checksums.txt" + + log "Downloading packaged release $RELEASE_TAG of $REPOSITORY" + download_file "$ASSET_BASE/checksums.txt?nocache=$NONCE" "$CHECKSUMS_PATH" + download_file "$ASSET_BASE/$PACKAGE_NAME?nocache=$NONCE" "$ARCHIVE_PATH" + verify_checksum "$ARCHIVE_PATH" "$CHECKSUMS_PATH" "$PACKAGE_NAME" + log "Checksum verified for $PACKAGE_NAME" +else + RESOLVED_COMMIT="$(resolve_remote_commit "$REPOSITORY" "$REF")" + ARCHIVE_PATH="$TEMP_ROOT/source.zip" + ARCHIVE_URL="https://github.com/$REPOSITORY/archive/$RESOLVED_COMMIT.zip?nocache=$NONCE" + log "Downloading $REPOSITORY at commit $RESOLVED_COMMIT" + download_file "$ARCHIVE_URL" "$ARCHIVE_PATH" +fi + log "Extracting template" unzip -q "$ARCHIVE_PATH" -d "$EXTRACT_PATH" ARCHIVE_ROOT="$(find "$EXTRACT_PATH" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + +if [ -n "$RELEASE_TAG" ] && [ -f "$ARCHIVE_ROOT/VERSION" ]; then + PACKAGE_VERSION="$(tr -d '[:space:]' < "$ARCHIVE_ROOT/VERSION")" + if [ "$PACKAGE_VERSION" != "$RELEASE_TAG" ]; then + echo "Package VERSION ($PACKAGE_VERSION) does not match requested release ($RELEASE_TAG)." >&2 + exit 1 + fi +elif [ -n "$PACKAGE_FILE" ] && [ -z "$RELEASE_TAG" ] && [ -f "$ARCHIVE_ROOT/VERSION" ]; then + RELEASE_TAG="$(tr -d '[:space:]' < "$ARCHIVE_ROOT/VERSION")" +fi SOURCE_COMMON="$ARCHIVE_ROOT/sample/common" SOURCE_AGENTIC="$SOURCE_COMMON/agentic-flow" SOURCE_COMMON_SKILLS="$SOURCE_COMMON/.agents/skills" @@ -799,6 +926,19 @@ esac set_root_integration_state "$TARGET_AGENTIC/SETTINGS.md" "$RESOLVED_ROOT_AGENTS_MODE" +printf '\n%s\n' "Codebase Learning Flow" +if [ -n "$RELEASE_TAG" ]; then + printf 'Version: %s\n' "$RELEASE_TAG" + if [ -n "$PACKAGE_FILE" ]; then + printf 'Source: packaged release (local package file, unverified)\n' + else + printf 'Source: packaged release (checksum verified)\n' + fi +else + printf 'Version: %s (ref: %s)\n' "$RESOLVED_COMMIT" "$REF" + printf 'Source: development checkout (mutable unless ref is a commit or tag)\n' +fi + log "Installation complete: profile=$SELECTED_PROFILE extension=$SELECTED_EXTENSION mode=$MODE root-agents=$RESOLVED_ROOT_AGENTS_MODE" printf '\n%s\n' "Suggested first instruction:" printf '%s\n' "Start with my current task. Quietly verify the installed workflow, surface only meaningful instruction conflicts, teach the relevant code and domain path while working, and persist only verified findings that will be useful again."