From 33255d087b506d36e34632034ec319c20ed86f70 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sat, 25 Jul 2026 20:28:41 -0400 Subject: [PATCH 01/23] docs: align privacy scanning and release guidance --- PRIVACY.md | 371 ++++++------------ QUICKSTART.md | 85 ++-- README.md | 141 ++++--- SECURITY.md | 26 +- cli/CHANGELOG.md | 71 ++++ cli/README.md | 149 ++++--- cli/docs/PERFORMANCE.md | 2 +- cli/docs/TESTING_TOOLS.md | 19 + docs/AI_QUICK_REFERENCE.md | 211 +++------- docs/API.md | 277 ++----------- docs/DOCKER_GUIDE.md | 21 +- docs/GETTING_STARTED.md | 100 +++-- docs/RATE_LIMITING.md | 295 +------------- docs/RELEASE_AUTOMATION.md | 190 +++++++++ docs/RELEASE_ONBOARDING.md | 87 ++++ docs/VULNERABILITY_SCANNING.md | 120 ++++++ docs/adrs/001-cloudflare-workers-backend.md | 11 +- docs/adrs/003-privacy-first-architecture.md | 325 +-------------- docs/adrs/005-byok-ai-model.md | 9 +- .../006-node-sea-standalone-distribution.md | 110 ++++++ docs/adrs/README.md | 5 +- tasks/plan.md | 368 +++++++++++++++++ tasks/todo.md | 81 ++++ 23 files changed, 1608 insertions(+), 1466 deletions(-) create mode 100644 cli/docs/TESTING_TOOLS.md create mode 100644 docs/RELEASE_AUTOMATION.md create mode 100644 docs/RELEASE_ONBOARDING.md create mode 100644 docs/VULNERABILITY_SCANNING.md create mode 100644 docs/adrs/006-node-sea-standalone-distribution.md create mode 100644 tasks/plan.md create mode 100644 tasks/todo.md diff --git a/PRIVACY.md b/PRIVACY.md index 1593489..2f471d2 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,313 +1,168 @@ # Privacy Policy -**Last Updated**: November 24, 2025 +**Last updated: July 20, 2026** -GuardScan is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and protect information when you use GuardScan. +GuardScan is a local-first security scanner and BYOK AI client. This document describes the CLI's network boundaries and the data it stores. It distinguishes local scanning, third-party AI providers, public vulnerability services, and optional GuardScan telemetry because they have different privacy properties. ---- +## Defaults -## 🎯 Core Privacy Principles +A new non-interactive installation starts with: -1. **Your source code NEVER leaves your machine** (for static analysis) -2. **No code is uploaded** to GuardScan servers -3. **AI features use YOUR API keys** - we never see your code -4. **Telemetry is optional** and can be disabled -5. **All data is anonymized** when telemetry is enabled +- offline mode enabled; +- telemetry disabled; +- no AI provider selected; and +- local caches enabled for commands that use them. ---- - -## πŸ“Š What Information We Collect - -### When Telemetry is Enabled (Optional) - -If you enable telemetry (default: enabled, can be disabled with `--no-telemetry`), we collect: - -1. **Client ID** - - Anonymous identifier generated on first run - - Used to track usage patterns (not personal identification) - - Stored locally in your configuration - -2. **Repository ID** - - Hash of your repository path - - Used to track repository-level statistics - - Cannot be reversed to identify your repository - -3. **Usage Statistics** - - Commands executed (e.g., "security", "scan", "run") - - Lines of code scanned (aggregate counts) - - Feature usage (which commands are used most) - - Error types (for debugging, no stack traces) - -4. **System Information** (Anonymized) - - Node.js version - - Operating system type (not specific version) - - GuardScan version - -### What We Do NOT Collect - -- ❌ **Source code** - Never collected or transmitted -- ❌ **File contents** - Never collected or transmitted -- ❌ **API keys** - Never collected or stored -- ❌ **Personal information** - No names, emails, or identifiers -- ❌ **Repository paths** - Only hashed repository IDs -- ❌ **File names** - Not collected -- ❌ **Git history** - Not collected -- ❌ **Network information** - No IP addresses stored - ---- - -## πŸ”’ How We Use Collected Information - -### Telemetry Data Usage - -When telemetry is enabled, we use the collected data to: - -1. **Product Improvement** - - Understand which features are most used - - Identify areas for improvement - - Prioritize development efforts - -2. **Bug Fixes** - - Identify common error patterns - - Improve error handling - - Enhance stability - -3. **Analytics** - - Aggregate usage statistics - - Measure adoption of features - - Track version distribution - -### Data Storage - -- **Location**: Cloudflare Workers (global edge network) -- **Database**: Supabase (PostgreSQL) -- **Retention**: Data is retained for up to 1 year -- **Security**: All data is encrypted in transit and at rest - ---- - -## 🚫 Disabling Telemetry - -You can disable telemetry at any time: - -### Per-Command +View the active settings with: ```bash -guardscan --no-telemetry security +guardscan config --show +guardscan telemetry status ``` -### Globally +## Source code and static analysis -```bash -# Edit your config file -guardscan config +GuardScan's built-in static scanners process source files locally. GuardScan does not send source code, source-derived prompts, file names, file paths, secrets, or API keys to a GuardScan-operated service. -# Or manually edit: ~/.guardscan/config.yaml -# Set telemetry.enabled: false -``` +Some commands deliberately invoke other software, such as a project test runner, linter, package manager, mutation tool, or configured AI provider. `guardscan scan` is static-only by default and requires `--run-project-code` before it invokes repository-controlled tests or linters. Those child processes receive a scrubbed environment and isolated temporary home, but they can still read accessible files and modify the repository. `--isolate-project-network` requests an OS-backed network sandbox and fails the affected check when that sandbox is unavailable. Other explicitly invoked project-tool commands retain their own behavior and privacy policies. -### Environment Variable +## Offline mode + +Persist offline mode or apply it to one invocation: ```bash -export GUARDSCAN_NO_TELEMETRY=true -guardscan security +guardscan config --offline=true +guardscan --offline security ``` ---- - -## πŸ€– AI Provider Privacy - -When you use AI features (code review, documentation generation, etc.): - -### Your API Keys - -- **Stored locally** in your configuration file (`~/.guardscan/config.yaml`) -- **Never transmitted** to GuardScan servers -- **Sent directly** to your chosen AI provider (OpenAI, Anthropic, etc.) - -### Your Code - -- **Code snippets** are sent to your AI provider (OpenAI, Claude, etc.) -- **GuardScan does NOT see** your code or AI responses -- **You control** which AI provider receives your code -- **Review your AI provider's privacy policy** (OpenAI, Anthropic, Google, etc.) - -### Local AI (Ollama) - -- When using Ollama, **everything stays local** -- No data leaves your machine -- No network requests to external services - ---- +`--no-cloud` remains a deprecated alias for `--offline`. -## πŸ“‘ Network Communication +While offline mode is active, GuardScan blocks its cloud AI providers and cloud embedding providers, advisory lookups, update checks, and both telemetry recording and delivery. Ollama and LM Studio remain available only at literal IPv4 `127/8` or IPv6 `::1` endpoints; hostname aliases, private-network addresses, and remote endpoints are rejected. HTTP redirects are disabled for provider and telemetry transports. Dependency inventory and SBOM generation continue from local manifests, lockfiles, and installed package metadata. -### Static Analysis (Offline) +Offline CVE scanning does not claim that an old local snapshot is current. It requires a fresh snapshot whose inventory digest matches the repository. Missing, stale, mismatched, or incomplete coverage is an operational failure unless `--allow-partial` is explicitly supplied. -- **No network requests** required -- Works completely offline -- No data transmission +## AI providers -### AI Features +AI features send selected repository context directly to the provider you configure. For OpenAI, Anthropic, Gemini, or OpenRouter, that is a third-party cloud service. For Ollama or LM Studio at a literal loopback address, it is a local service. A non-loopback self-hosted endpoint requires offline mode to be disabled and the separately named `allowRemoteSelfHosted: true` configuration approval. -- **Direct connection** to your AI provider -- No GuardScan servers involved -- Your code goes directly to OpenAI/Claude/etc. +`guardscan run` always completes its required local scanner pass before optional AI enrichment. Scanner status, errors, and coverage remain in the report; incomplete required coverage exits with code `2` unless the supported CVE-only partial policy was explicitly selected. -### Telemetry (If Enabled) +GuardScan does not proxy AI requests or receive the provider response. Review the chosen provider's retention and training policies before enabling cloud AI. API credentials can be read from the provider's environment variable or stored in the local configuration file; GuardScan does not include them in telemetry. -- **HTTPS only** - All data encrypted in transit -- **Minimal data** - Only metadata, no code -- **Optional** - Can be completely disabled +## Vulnerability advisory lookups -### Version Checking +Online vulnerability scans send package ecosystem, package name, and exact version to the configured OSV-compatible endpoint. When known-exploitation enrichment is enabled, GuardScan also downloads the public CISA KEV catalog; that request contains no repository or package data. Neither request sends source files. Successful OSV coverage and the validated KEV catalog can be cached locally for later offline use. -- **npm registry** - Checks for updates -- **No personal data** sent -- **Can be disabled** by setting environment variable +Snapshots are stored beneath `~/.guardscan/cache/vulnerabilities` (or the equivalent directory under `GUARDSCAN_HOME`). Clear them with: ---- - -## πŸ” Data Security - -### How We Protect Your Data - -1. **Encryption** - - All data encrypted in transit (HTTPS/TLS) - - Database encryption at rest - - Secure API endpoints - -2. **Access Control** - - Limited access to telemetry data - - No access to source code (we don't collect it) - - Regular security audits - -3. **Infrastructure** - - Cloudflare Workers (edge network) - - Supabase (PostgreSQL database) - - Industry-standard security practices - -### Your Data Security - -1. **Local Storage** - - Configuration files stored locally - - API keys stored in local config - - Cache files stored locally - -2. **Best Practices** - - Don't commit config files to version control - - Use environment variables for sensitive data - - Regularly rotate API keys - ---- - -## 🌍 Data Location - -- **Telemetry Data**: Stored in Supabase (PostgreSQL) - location depends on your Supabase region -- **Processing**: Cloudflare Workers (global edge network) -- **Your Code**: Never stored anywhere - stays on your machine - ---- - -## πŸ‘₯ Third-Party Services - -GuardScan uses the following third-party services: - -### Required Services - -- **npm Registry**: For package installation and version checking -- **AI Providers** (if configured): OpenAI, Anthropic, Google, Ollama - -### Optional Services (Telemetry) - -- **Cloudflare Workers**: Backend API for telemetry -- **Supabase**: Database for telemetry storage - -**Note**: When telemetry is disabled, no data is sent to Cloudflare or Supabase. - ---- - -## πŸ”„ Data Retention and Deletion - -### Telemetry Data - -- **Retention**: Up to 1 year -- **Deletion**: You can request deletion by emailing -- **Anonymization**: Data is anonymized and cannot be linked to individuals - -### Local Data - -- **Configuration**: Stored locally, you control it -- **Cache**: Stored locally, can be cleared with `guardscan reset` -- **Logs**: Stored locally, you control retention +```bash +guardscan vuln db clear --repo --force +guardscan vuln db clear --all --force +``` ---- +See [Vulnerability Scanning](./docs/VULNERABILITY_SCANNING.md) for coverage and severity limitations. -## πŸ‘Ά Children's Privacy +## Telemetry -GuardScan is not intended for users under 13 years of age. We do not knowingly collect personal information from children. +Telemetry is opt-in and never uploads automatically. Enabling consent allows GuardScan to queue an allowlisted event locally; delivery happens only when you run `guardscan telemetry sync`. ---- +An event may contain: -## πŸ”„ Changes to This Policy +- a random event ID; +- the command category; +- aggregate lines of code; +- execution duration; +- a coarse execution mode; and +- an event timestamp. -We may update this Privacy Policy from time to time. Changes will be: +Events do not contain source code, prompts, responses, file names, file paths, repository names, repository hashes, stack traces, API keys, dependency names, or vulnerability details. -- Posted on this page -- Dated with "Last Updated" timestamp -- Communicated via GitHub releases for significant changes +The telemetry schema is exact: `eventId`, `action`, `loc`, `durationMs`, +`executionMode`, and `occurredAt`. Unknown fields, invalid numeric values, and +timestamps outside the supported range are quarantined locally before status, +pruning, or synchronization. Quarantined files are never uploaded. ---- +Maintenance retains the newest 1,000 valid events for at most 30 days. Separate +CLI processes publish without replacing one another, so concurrent writers may +briefly exceed the retention target until the next bounded maintenance pass. A +single pass examines at most 2,000 directory entries. Event and metadata files +are limited to 64 KiB; legacy migration inputs are limited to 8 MiB and 1,000 +events. Invalid migrations are quarantined without partially importing them, +and only one process may synchronize an outbox at a time. A failed upload +remains queued for a later explicit retry. A successful response removes only +accepted events. -## πŸ“§ Contact Us +To use telemetry, enable consent, leave offline mode, configure an HTTPS collector, and sync explicitly: -For privacy-related questions or concerns: +```bash +guardscan config --telemetry=true --offline=false +export GUARDSCAN_TELEMETRY_URL=https://telemetry.example.com +guardscan telemetry status +guardscan telemetry sync +``` -- **Email**: -- **GitHub Issues**: For general questions (not sensitive privacy matters) +`GUARDSCAN_OFFLINE=true`, `--offline`, `GUARDSCAN_NO_TELEMETRY=true`, or `--no-telemetry` suppresses recording and delivery for the invocation. Persistently disabling telemetry also exhaustively deletes every queued event, including queues larger than the retention limit. Inspect or delete the local outbox at any time: ---- +```bash +guardscan telemetry status +guardscan telemetry clear --force +``` -## βœ… Your Rights +No hosted telemetry endpoint is configured by default. Whoever operates the endpoint is responsible for publishing its retention, deletion, access-control, and jurisdiction terms. -You have the right to: +## Local storage -1. **Disable telemetry** at any time -2. **Request data deletion** (email ) -3. **Access your data** (if telemetry is enabled) -4. **Use GuardScan completely offline** (no telemetry, no network) +GuardScan stores configuration and state beneath `~/.guardscan`, or beneath the directory selected by `GUARDSCAN_HOME`. Depending on enabled features, local data can include: ---- +- configuration and API credentials; +- exact and semantic AI caches containing prompts, responses, file paths, and code-derived text; +- local embedding indexes; +- vulnerability coverage snapshots; +- circuit-breaker and metrics state; and +- the telemetry outbox. -## πŸ“‹ Summary +GuardScan creates its private state directories with mode `0700` and sensitive +files with mode `0600` on platforms that support POSIX permissions. Local AI +metrics retain provider/model names, timing, token counts, cost estimates, and +structured error categories, but do not persist raw provider error messages. +Metrics are stored as bounded per-span event files so concurrent CLI processes +do not replace one another's snapshots. Persisted metrics use an exact, +allowlisted schema; malformed optional fields are quarantined before +aggregation. Metrics maintenance retains the newest 1,000 valid spans, limits +individual event files to 64 KiB and legacy inputs to 8 MiB/1,000 spans, and +keeps at most 20 recent quarantine artifacts per state area. A conflicting +write for the same span identity is rejected rather than silently replacing +the first value. -**What GuardScan Collects (if telemetry enabled):** +Configuration sections are parsed as partial overrides and normalized against +defaults. Unknown keys, invalid ranges, non-integer limits, malformed dates, +and unsafe service endpoints are rejected instead of being retained and +re-emitted. -- Anonymous client ID -- Hashed repository ID -- Usage statistics (commands, LOC counts) -- System information (Node.js version, OS type) +If GuardScan cannot resolve a safe home directory it fails closed and asks for +an absolute `GUARDSCAN_HOME`; it does not silently use the shared `/tmp` root. -**What GuardScan Does NOT Collect:** +Cache entries expire according to the configured TTL and are pruned from disk. `--no-cache` disables exact, semantic, and vulnerability snapshot reads and writes for that invocation. -- Source code -- File contents -- API keys -- Personal information -- Repository paths -- File names +Delete cached source-derived data with: -**Your Control:** +```bash +guardscan cache clear --repo --force +guardscan cache clear --all --force +``` -- βœ… Disable telemetry anytime -- βœ… Use completely offline -- βœ… Control your API keys -- βœ… Your code never leaves your machine (for static analysis) +`cache clear` does not delete the telemetry outbox. Use `telemetry clear` for that data, or `guardscan reset --all --force` when you intend to reset all GuardScan configuration and state. ---- +## Your controls -**GuardScan is committed to privacy-first development. Your code stays yours.** +You can: ---- +- keep GuardScan offline; +- choose a local or cloud AI provider; +- omit AI features entirely; +- disable cache reads and writes per invocation; +- inspect and clear repository or global caches; +- leave telemetry disabled; and +- inspect, explicitly synchronize, or delete queued telemetry. -*Last Updated: 2025-11-24* +For a sensitive privacy question, contact . Do not include source code, credentials, or proprietary findings in a public issue. diff --git a/QUICKSTART.md b/QUICKSTART.md index 7ed0ba9..8949369 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -2,7 +2,7 @@ **Get started with GuardScan in under 2 minutes!** -GuardScan is a privacy-first, open-source security scanning and AI code review CLI. All static analysis features work **100% free and offline** - no API keys required! +GuardScan is a privacy-first, open-source security scanning and AI code review CLI. Local static analysis and SBOM inventory work offline without an API key. Current CVE data requires OSV access or a fresh matching local snapshot. --- @@ -18,8 +18,8 @@ guardscan --version **Requirements:** -- Node.js >= 18.0.0 -- npm or yarn +- Node.js >= 22.0.0 +- npm (pnpm, Yarn, and Bun compatibility is release-gated before those install paths are advertised) --- @@ -31,25 +31,25 @@ guardscan --version guardscan init ``` -This creates a local configuration file and generates a client ID for optional telemetry. +This creates a private local configuration file. GuardScan does not generate or transmit an installation identifier. ### Step 2: Run Your First Security Scan ```bash -# Scan your current project (100% FREE, works offline) -guardscan security +# Scan your current project with local checks only +guardscan security --offline --no-cve ``` This will: - βœ… Detect secrets in your code (API keys, passwords, tokens) -- βœ… Scan dependencies for known vulnerabilities +- βœ… Scan dependencies for known vulnerabilities when OSV or a matching snapshot is available - βœ… Check Dockerfiles for security issues - βœ… Analyze Infrastructure as Code (Terraform, CloudFormation, K8s) - βœ… Detect OWASP Top 10 vulnerabilities - βœ… Generate a comprehensive markdown report -**No API key needed** - all security scanning works completely offline! +**No API key needed** - local security scanning and SBOM inventory work offline. Current CVE results need OSV access or a fresh matching local snapshot. ### Step 3: (Optional) Configure AI Provider @@ -71,15 +71,17 @@ Follow the prompts to set: ## πŸ“‹ Available Commands -GuardScan provides **21 commands** organized by category: +GuardScan provides **28 top-level commands** organized by category: ### Setup & Configuration ```bash -guardscan init # Initialize GuardScan (generates client_id for telemetry) +guardscan init # Initialize local GuardScan configuration guardscan config # Configure AI provider and settings (OpenAI, Claude, Gemini, Ollama) -guardscan status # Show current status (credits, provider, repo info) +guardscan status # Show provider, repo, and local config status guardscan reset # Clear local cache and config +guardscan cache # Inspect or clear repository/global cache +guardscan telemetry # Inspect, explicitly sync, or clear telemetry ``` ### Security & Scanning (Offline-Capable, 100% FREE) @@ -90,6 +92,7 @@ guardscan scan # Comprehensive scan (all security and quality guardscan test # Run tests and code quality analysis guardscan sbom # Generate Software Bill of Materials (SBOM) guardscan rules # Run custom YAML-based rules engine +guardscan vuln # Audit exact dependency versions with OSV ``` ### Testing & Performance @@ -129,6 +132,15 @@ guardscan migrate # AI-powered code migration assistant guardscan chat # Interactive AI chat about your codebase (RAG feature) ``` +### AI Model Operations + +```bash +guardscan models # Inspect supported AI models +guardscan routing # Configure task-to-model routing +guardscan budget # Inspect and configure local budget limits +guardscan metrics # Inspect locally recorded AI metrics +``` + --- ## πŸ’‘ Common Use Cases @@ -140,12 +152,20 @@ guardscan chat # Interactive AI chat about your codebase (RAG guardscan security # Check for dependency vulnerabilities -guardscan security --licenses +guardscan config --offline=false +guardscan vuln . --ci --format json --output vulnerabilities.json # Generate SBOM for compliance guardscan sbom --format spdx +guardscan sbom --format cyclonedx ``` +SBOM output is validated against the official SPDX 2.3 and CycloneDX 1.7 JSON schemas. + +For the combined security, quality, and SBOM workflow, `guardscan scan` remains +static-safe by default. Only add `--run-project-code` for a repository you trust; +add `--isolate-project-network` when you also require an available OS network sandbox. + ### AI-Powered Code Review ```bash @@ -195,21 +215,23 @@ guardscan chat ### What GuardScan Does - βœ… Scans your code **locally** on your machine -- βœ… Never uploads source code to any server -- βœ… Works **completely offline** for static analysis +- βœ… Does not upload source code to GuardScan servers +- βœ… Runs local static analysis offline +- βœ… Does not execute repository tests or linters during `guardscan scan` unless explicitly enabled - βœ… Uses your own AI API keys (BYOK - Bring Your Own Key) -### What GuardScan Sends (Optional Telemetry) +### Optional telemetry -- Client ID (anonymous identifier) -- Repository ID (hashed, anonymous) -- Lines of code count -- Command usage statistics +- Telemetry is disabled by default. +- After consent, GuardScan queues only action, aggregate LOC, duration, coarse execution mode, event ID, and timestamp. +- Source, paths, prompts, responses, findings, dependency names, and errors are excluded. +- Delivery happens only through `guardscan telemetry sync` to an HTTPS endpoint you configure. -**You can disable telemetry:** +Inspect or suppress it: ```bash guardscan --no-telemetry security +guardscan telemetry status ``` --- @@ -218,23 +240,36 @@ guardscan --no-telemetry security ### Offline-First Architecture -**Static Analysis** (Works completely offline, 100% FREE): +**Static Analysis** (Offline-first, 100% FREE): - Secrets detection (20+ patterns) -- Dependency vulnerability scanning +- Dependency inventory and vulnerability evaluation from a fresh matching snapshot - Code metrics and complexity analysis - LOC counting (20+ languages) - OWASP Top 10 detection - Docker security scanning - Infrastructure as Code analysis +OSV lookups and snapshot refreshes require online mode: + +```bash +guardscan config --offline=false +guardscan vuln db update . +guardscan vuln . +``` + +See [Dependency Vulnerability Scanning](./docs/VULNERABILITY_SCANNING.md) for supported lockfiles, limitations, and CI policy. + **AI-Enhanced** (Optional, requires your API key): - OpenAI GPT-4, GPT-3.5 - Anthropic Claude (Opus, Sonnet, Haiku) - Google Gemini -- Ollama (local/offline AI) -- LM Studio (local AI) +- Ollama (offline only at literal `127/8` or `::1` endpoints) +- LM Studio (offline only at literal `127/8` or `::1` endpoints) + +Remote self-hosted Ollama or LM Studio endpoints require online mode and the +explicit `allowRemoteSelfHosted: true` configuration approval. --- @@ -313,7 +348,7 @@ guardscan init 1. βœ… **Install**: `npm install -g guardscan` 2. βœ… **Initialize**: `guardscan init` -3. βœ… **Scan**: `guardscan security` (works offline, 100% free!) +3. βœ… **Scan**: `guardscan security --offline --no-cve` (local checks, 100% free) 4. βœ… **Configure AI** (optional): `guardscan config` 5. βœ… **Explore**: Try `guardscan --help` to see all commands diff --git a/README.md b/README.md index c8bb648..aede9d4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ``` [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -[![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org) +[![Node.js Version](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen)](https://nodejs.org) --- @@ -25,8 +25,8 @@ GuardScan is **100% free and open source**! No credit system, no paywalls, no su - βœ… **Unlimited static analysis** - 9 security scanners + code quality tools - βœ… **AI-enhanced code review** - Bring your own API key (OpenAI, Claude, Gemini, Ollama) -- βœ… **Works fully offline** - No internet required for static analysis -- βœ… **Privacy-first** - Never uploads your source code +- βœ… **Offline-first static analysis** - Local scanners and SBOM inventory run without internet; CVEs can reuse a fresh matching snapshot +- βœ… **Privacy-first** - GuardScan does not upload your source code to GuardScan servers - βœ… **No usage limits** - Scan unlimited LOC, unlimited repositories --- @@ -40,9 +40,13 @@ npm install -g guardscan # Initialize GuardScan guardscan init -# Run comprehensive security scan (100% FREE, offline) +# Run comprehensive security scan (100% FREE, offline-first) guardscan security +# Audit exact dependency versions against OSV (online) +guardscan config --offline=false +guardscan vuln . + # Configure AI provider for enhanced review (optional, BYOK) guardscan config @@ -81,12 +85,12 @@ guardscan init ## πŸ“‹ Core Features -### πŸ”’ Security Scanning (FREE, Offline) +### πŸ”’ Security Scanning (FREE, Offline-First) GuardScan includes **comprehensive security scanners**: 1. **Secrets Detection** - Find hardcoded API keys, passwords, tokens (20+ patterns) -2. **Dependency Vulnerabilities** - Scan npm, pip, Maven, Cargo dependencies +2. **Dependency Vulnerabilities** - Scan exact npm, PyPI, Go, RubyGems, Cargo, and Maven versions with OSV; reuse fresh snapshots offline 3. **OWASP Top 10** - SQL injection, XSS, insecure configs, CSRF, XXE 4. **Docker Security** - Dockerfile and container scanning 5. **Infrastructure as Code** - Terraform, CloudFormation, Kubernetes security @@ -160,7 +164,7 @@ All commands are **100% FREE** with no limits! | Command | Description | | ------------------ | ------------------------------------- | -| `guardscan init` | Initialize config, generate client_id | +| `guardscan init` | Initialize local configuration | | `guardscan config` | Configure AI provider & settings | | `guardscan status` | Show configuration and repo info | | `guardscan reset` | Clear local cache & config | @@ -169,9 +173,27 @@ All commands are **100% FREE** with no limits! | Command | Description | | -------------------- | ----------------------------------------- | -| `guardscan security` | Run comprehensive security scan (offline) | -| `guardscan scan` | Quick security scan | -| `guardscan run` | AI-enhanced full code review (BYOK) | +| `guardscan security` | Run comprehensive local security scan | +| `guardscan scan` | Static-safe security, quality, and SBOM scan | +| `guardscan vuln` | Audit exact dependency versions with OSV | +| `guardscan run` | Required local review with optional AI enrichment | + +Dependency scanning details, offline snapshots, severity limitations, and CI examples are documented in [Dependency Vulnerability Scanning](./docs/VULNERABILITY_SCANNING.md). + +### CI output and exit codes + +```bash +guardscan security --ci --format json --output guardscan.json --fail-on high +guardscan security --format sarif --output guardscan.sarif +``` + +Structured scan output uses the `guardscan.scan.v1` envelope and includes security, quality, SBOM, AI, scanner status, errors, and policy state. Exit code `0` means the run and policy passed, `1` means findings violated policy, and `2` means scanner coverage or execution failed. `--allow-partial` is an explicit relaxation for incomplete coverage. + +`guardscan scan` does not execute repository-controlled tests or linters by default. Use +`--run-project-code` only for a trusted repository; reports record whether they are +`static-analysis` or `project-code-executed`. Child processes receive a scrubbed +environment and isolated home. `--isolate-project-network` additionally requests an +OS-backed network sandbox and fails the affected checks if the platform sandbox is unavailable. ### Testing & Quality Commands @@ -187,8 +209,10 @@ All commands are **100% FREE** with no limits! | Command | Description | | ----------------- | ----------------------------------- | -| `guardscan sbom` | Generate Software Bill of Materials | +| `guardscan sbom` | Generate schema-valid SPDX 2.3 or CycloneDX 1.7 | | `guardscan rules` | Custom YAML-based rule engine | +| `guardscan cache` | Inspect or clear local AI caches | +| `guardscan telemetry` | Inspect, sync, or clear opt-in telemetry | ### AI-Powered Commands (BYOK) @@ -210,7 +234,7 @@ All commands are **100% FREE** with no limits! We take privacy seriously: -### ❌ Never Stored or Transmitted +### ❌ Never Sent to GuardScan Servers - Your source code - File paths or file names @@ -218,25 +242,29 @@ We take privacy seriously: - API keys or secrets - Proprietary information -### βœ… Optional Telemetry (Anonymized) +Local AI/RAG features can store prompts, responses, file paths, and code-derived snippets in your machine's `~/.guardscan/cache` for caching and retrieval. Use `guardscan cache clear --repo --force` or `guardscan cache clear --all --force` to remove cached source-derived data. + +### βœ… Opt-In Aggregate Telemetry - Command usage (e.g., "security" command ran) - Execution duration - LOC count (aggregate number only) -- AI model used (e.g., "gpt-4") +- Coarse execution mode (static, local AI, or cloud AI) **Telemetry is:** -- Optional (easily disabled: `guardscan config --telemetry=false`) -- Completely anonymized -- Only used to improve GuardScan -- Never sold or shared +- Disabled by default and explicitly enabled with `guardscan config --telemetry=true` +- Queued locally only after consent +- Sent only by `guardscan telemetry sync` to an endpoint you configure +- Recording and delivery are suppressed by offline mode or `--no-telemetry` + +See the [Privacy Policy](./PRIVACY.md) for the exact event allowlist and local retention behavior. --- ## 🎯 How It Works -### Static Analysis (Offline, No AI) +### Static Analysis (Offline-First, No AI) ```bash guardscan security @@ -246,7 +274,7 @@ Runs **9 security scanners** locally: - Scans your codebase - Generates markdown report -- **100% offline** - no internet needed +- **Offline-first** - local scanners and local SBOM inventory run without internet; CVE results require either OSV access or a fresh matching snapshot - **100% free** - no limits ### AI-Enhanced Review (Your API Key) @@ -263,10 +291,11 @@ guardscan run How it works: -1. GuardScan analyzes your code locally -2. Sends anonymized context to **your AI provider** (using **your API key**) -3. AI provides insights and suggestions -4. Report saved locally +1. GuardScan runs its required local security and quality analysis. +2. If configured, it sends selected source-derived context to **your AI provider** using **your API key**. +3. AI findings enrich the local report; they never replace scanner coverage. +4. Incomplete required coverage is recorded in the report and exits with code `2`. +5. The report is saved locally. **You pay your AI provider directly** - GuardScan is free! @@ -280,18 +309,12 @@ No credit system. No subscriptions. No paywalls. ### AI Providers (If You Use AI Features) -**You pay them directly (not GuardScan):** - -- **OpenAI GPT-4**: ~$0.01-0.03 per 1K tokens -- **Claude Sonnet**: ~$0.003 per 1K tokens -- **Gemini Pro**: Free tier available -- **Ollama**: 100% free (runs locally) - -**Example costs for 10K LOC codebase:** +**You pay providers directly (not GuardScan):** -- Static analysis only: **$0** -- With OpenAI GPT-4: **~$2-5** (paid to OpenAI) -- With Ollama (local): **$0** +- Cloud providers bill through your own account and pricing plan. +- Ollama and LM Studio are local in offline mode only at literal `127/8` or `::1` endpoints. + Remote self-hosted endpoints require online mode and `allowRemoteSelfHosted: true`. +- Static analysis only does not require an AI provider. --- @@ -306,7 +329,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ GuardScan CLI (Node.js/TypeScript) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ β€’ 21 Commands (security, run, test, explain...) β”‚ β”‚ +β”‚ β”‚ β€’ 28 Commands (security, vuln, run, test...) β”‚ β”‚ β”‚ β”‚ β€’ 30 Core Modules (scanners, parsers, metrics) β”‚ β”‚ β”‚ β”‚ β€’ 9 AI Features (explain, review, test-gen, etc.) β”‚ β”‚ β”‚ β”‚ β€’ 7 Language Parsers (Python, Java, Go, Rust...) β”‚ β”‚ @@ -316,7 +339,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a β”‚ β”‚ Cache: ~/.guardscan/cache/ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ Optional telemetry only β”‚ +β”‚ β”‚ Explicit network actions only β”‚ β”‚ β–Ό β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ @@ -324,35 +347,34 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a β”‚ β”‚ β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ User's AI Provider β”‚ β”‚ GuardScan Backend β”‚ -β”‚ (User pays directly) β”‚ β”‚ (Optional telemetry) β”‚ +β”‚ User's AI Provider β”‚ β”‚ Configured HTTPS β”‚ +β”‚ (User pays directly) β”‚ β”‚ telemetry endpoint β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β€’ OpenAI (GPT-4) β”‚ β”‚ Cloudflare Workers β”‚ -β”‚ β€’ Anthropic (Claude) β”‚ β”‚ + Supabase β”‚ -β”‚ β€’ Google (Gemini) β”‚ β”‚ β”‚ -β”‚ β€’ Ollama (Local) β”‚ β”‚ β€’ Health checks β”‚ -β”‚ β”‚ β”‚ β€’ Anonymous telemetry β”‚ +β”‚ β€’ OpenAI β”‚ β”‚ β€’ Explicit sync only β”‚ +β”‚ β€’ Anthropic β”‚ β”‚ β€’ Aggregate allowlist β”‚ +β”‚ β€’ Google Gemini β”‚ β”‚ β€’ No default endpoint β”‚ +β”‚ β€’ Ollama/LM Studio β”‚ β”‚ β€’ No source or findings β”‚ β”‚ User's API Key β†’ β”‚ β”‚ β€’ NO source code β”‚ -β”‚ User's billing β†’ β”‚ β”‚ β€’ NO credit validation β”‚ +β”‚ User controls billing β”‚ β”‚ β€’ Optional telemetry β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ### Technology Stack -**CLI (34,213 LOC):** +**CLI:** - Language: TypeScript 5.3+ (strict mode) -- Runtime: Node.js 18+ +- Runtime: Node.js 22+ - Framework: Commander.js -- Testing: Jest (70%+ coverage) +- Testing: Jest with enforced coverage thresholds - Build: TypeScript Compiler (tsc) -**Monitoring service (separate repo, optional):** +**Telemetry collector (optional):** -- Lives in [ntanwir10/GuardScan-Monitoring](https://github.com/ntanwir10/GuardScan-Monitoring) -- Platform: Cloudflare Workers + Supabase PostgreSQL -- Purpose: Anonymous telemetry and error/usage analytics -- The CLI talks to it over HTTP only; override the URL with `GUARDSCAN_API_URL` +- The CLI has no hosted endpoint configured by default. +- Operators can deploy any compatible HTTPS collector. +- Delivery occurs only through `guardscan telemetry sync` after setting `GUARDSCAN_TELEMETRY_URL`. +- The payload is the strict aggregate event allowlist described in [PRIVACY.md](./PRIVACY.md); errors and findings are excluded. --- @@ -392,18 +414,16 @@ GuardScan is **open source** and we welcome contributions! - **Report bugs**: [GitHub Issues](https://github.com/ntanwir10/GuardScan/issues) - **Request features**: [GitHub Issues](https://github.com/ntanwir10/GuardScan/issues) -- **Submit PRs**: See [CONTRIBUTING.md](docs/CONTRIBUTING.md) +- **Submit PRs**: Open a focused pull request with tests and a clear rationale --- ## πŸ“š Documentation - [Installation Guide](docs/GETTING_STARTED.md) -- [Configuration Guide](docs/CONFIGURATION.md) - [Chat Guide](docs/CHAT_GUIDE.md) - [API Documentation](docs/API.md) -- [Security Scanners](docs/SECURITY_SCANNERS.md) -- [Contributing Guidelines](docs/CONTRIBUTING.md) +- [Dependency Vulnerability Scanning](docs/VULNERABILITY_SCANNING.md) --- @@ -422,10 +442,10 @@ A: Only if you want AI-enhanced review. Static analysis (9 security scanners) wo A: Your choice! OpenAI (powerful), Claude (balanced), Gemini (affordable), Ollama (free, local). **Q: Does GuardScan upload my code?** -A: **Never**. GuardScan only uploads anonymized metadata for optional telemetry. +A: GuardScan does not upload source code to GuardScan servers. If you enable AI features, prompts are sent directly to your configured provider; local caches may store prompts/responses on your machine. **Q: Can I disable telemetry?** -A: Yes! Run `guardscan config --telemetry=false` or set `telemetryEnabled: false` in `~/.guardscan/config.yml`. +A: Yes. Telemetry is disabled by default. `guardscan config --telemetry=false` persists the setting and deletes queued events, while `--no-telemetry` suppresses one invocation. **Q: How do I support this project?** A: Star the repo on GitHub, contribute code, report bugs, or sponsor the project! @@ -445,8 +465,7 @@ GuardScan is built with these amazing open-source tools: - [Commander.js](https://github.com/tj/commander.js) - CLI framework - [Chalk](https://github.com/chalk/chalk) - Terminal styling - [Axios](https://github.com/axios/axios) - HTTP client -- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless backend -- [Supabase](https://supabase.com/) - Open-source Firebase alternative +- [OSV](https://osv.dev/) - Open vulnerability data and package-version queries --- diff --git a/SECURITY.md b/SECURITY.md index 5caa0b3..2f37f15 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,6 +6,7 @@ We actively support the following versions of GuardScan with security updates: | Version | Supported | | ------- | ------------------ | +| 1.1.x | :white_check_mark: | | 1.0.x | :white_check_mark: | | < 1.0 | :x: | @@ -76,8 +77,9 @@ We follow responsible disclosure practices: 5. **Network Security** - GuardScan works offline for static analysis - - Only AI features require network access - - Use `--no-telemetry` if you prefer not to send any data + - Cloud AI and fresh CVE lookups require network access; local AI is offline-only at literal loopback IP endpoints + - Use `--offline` to block GuardScan cloud, advisory, update, and telemetry clients + - Telemetry is opt-in and delivered only by an explicit `guardscan telemetry sync` ### For Developers @@ -108,6 +110,9 @@ GuardScan executes code analysis **locally** on your machine. This means: - βœ… No risk of code exposure through network transmission - ⚠️ GuardScan has read access to files you scan - ⚠️ Ensure you trust the codebase you're scanning +- βœ… `guardscan scan` does not execute repository-controlled code by default +- ⚠️ `--run-project-code` can run tests and linters that read files, change the repository, or use the network +- βœ… Child environments are scrubbed; `--isolate-project-network` requests an OS-backed network sandbox and fails if it is unavailable ### AI Provider Integration @@ -117,25 +122,30 @@ When using AI features: - API keys are sent directly to your chosen AI provider (OpenAI, Anthropic, etc.) - GuardScan does not store or log your API keys - Review your AI provider's privacy policy +- Offline Ollama and LM Studio endpoints must use literal `127/8` or `::1`; remote self-hosted endpoints require online mode plus `allowRemoteSelfHosted: true` ### Telemetry (Optional) If telemetry is enabled: -- Only metadata is sent (client_id, repo_id hash, LOC counts) -- No source code is transmitted -- You can disable with `--no-telemetry` flag -- Data is sent to our Cloudflare Workers backend (see PRIVACY.md) +- Only action, aggregate LOC, duration, coarse execution mode, event ID, and timestamp are queued +- Source, paths, prompts, responses, findings, dependency names, and errors are excluded +- Events remain local until `guardscan telemetry sync` is run against a configured HTTPS endpoint +- You can suppress an invocation with `--no-telemetry` and inspect or clear the queue with `guardscan telemetry` ### Dependency Scanning GuardScan scans your dependencies for known vulnerabilities: -- Uses public vulnerability databases (npm audit, etc.) +- Uses exact local dependency inventory, the public OSV database, and optional CISA KEV enrichment +- Offline results require a fresh snapshot matching the current inventory +- Missing or incomplete coverage fails closed unless `--allow-partial` is explicit - Results are based on publicly available CVE data - May have false positives or miss zero-day vulnerabilities - Always verify critical findings independently +See [Dependency Vulnerability Scanning](./docs/VULNERABILITY_SCANNING.md) for CVSS, CISA KEV, lockfile, and coverage limitations. + --- ## Security Features @@ -184,4 +194,4 @@ We appreciate the security research community's efforts to keep GuardScan secure --- -**Last Updated**: 2025-11-24 +**Last Updated**: 2026-07-13 diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index b6eb35b..eb28d21 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -5,6 +5,77 @@ All notable changes to GuardScan will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Static-safe `guardscan scan` execution with explicit `--run-project-code` trust capability, scrubbed child environments, report execution-mode metadata, and optional OS-backed network isolation. +- Official SPDX 2.3 and CycloneDX 1.7 serializers with vendored schema validation. +- Exact vulnerability snapshot validation and tri-state CISA KEV coverage metadata. +- Versioned release-manifest and release-state contracts with deterministic planning, dry-run, preparation, status, and resume tooling. +- Manifest-bound stable-promotion approval evidence, atomic publication-ledger transitions, and deterministic native package adapter validation. +- A fail-closed host-native Node.js SEA prototype builder and five-target non-publishable CI feasibility matrix. +- Packed-artifact compatibility gates for npm, pnpm, Yarn Modern, Yarn Classic, and Bun. + +### Changed + +- `guardscan run` now always executes required local scanners before optional AI enrichment and records incomplete coverage as operational exit code `2`. +- Offline Ollama and LM Studio endpoints now require literal loopback IPs. Remote self-hosted endpoints require online mode and `allowRemoteSelfHosted: true`. +- Telemetry and metrics retention, clearing, status, and migration now process the complete state directory with journaled rollback. +- Configuration parsing is size-, depth-, node-, alias-, and schema-bounded; read-modify-write updates are lease-serialized across processes. +- The supported Node.js runtime floor is now 22, with package smoke coverage on Node 22 and 24 across Linux, macOS, and Windows. + +### Fixed + +- Telemetry clearing and retention leaving events stranded beyond the former directory scan limit. +- Stale lease owners deleting a newer owner’s lease under normal ownership checks. +- Partially committed telemetry or metrics migrations after metadata or identity conflicts. +- Redirect-following provider and telemetry transports. +- Deleted lint-baseline source files bypassing baseline review. +- Yarn installation failing on an unresolvable stale optional tokenizer package. + +The strict ESLint gate remains a per-file non-regression ratchet; this release does not claim that the existing codebase is lint-clean. + +## [1.1.0] - 2026-07-13 + +### Added + +- Native OSV-backed dependency vulnerability scanning with `guardscan vuln`, `cve`, and `audit` commands. +- Exact-version inventory support across JavaScript, Python, Go, Rust, Ruby, and Maven projects. +- Offline vulnerability coverage snapshots and explicit database status/update/clear commands. +- Stable finding fingerprints, deterministic scanner execution, comprehensive versioned JSON, and schema-valid SARIF. +- Explicit telemetry status, sync, and clear commands with a strict privacy allowlist. +- Repository-scoped and global cache clearing. +- Windows and expanded Node.js CI coverage plus installed-package smoke tests. + +### Changed + +- Scanner failures and incomplete vulnerability coverage now fail closed with typed exit code `2` unless partial execution is explicitly allowed. +- CVE scanning is enabled by default when supported dependency manifests are present. +- Offline policy is enforced centrally for cloud AI, embeddings, update checks, telemetry, and advisory lookups. +- Environment-only cloud credentials and keyless Ollama/LM Studio configurations are supported. +- LM Studio endpoints are normalized to the documented `/v1` base path. +- Telemetry is opt-in and delivered only through an explicit sync command. +- License, SBOM, and vulnerability features share local dependency inventory. + +### Fixed + +- Persisted metrics can no longer poison aggregation or crash `metrics show` through malformed optional fields. +- Telemetry status, retention, and synchronization now quarantine invalid or future-dated events before use. +- Legacy metrics and telemetry migrations validate completely before publishing any migrated event. +- Local metrics, telemetry, vulnerability, and CISA state now share bounded reads, atomic private writes, and bounded quarantine retention. +- Concurrent telemetry synchronization and conflicting same-identity metrics writes are serialized or rejected explicitly. +- Partial configuration sections are normalized against defaults, while unknown keys and unsafe values are rejected. +- Clean-checkout lint ratchet packaging. +- Fail-open dependency and security scanners. +- Empty offline SBOMs. +- Incomplete comprehensive JSON reports. +- Invalid SARIF pseudo-fixes. +- Semantic caching continuing under `--no-cache`. +- Expired cache entries remaining on disk. +- Release publishing without every quality gate. +- Windows `npm`/`npx` command resolution and shell-dependent child processes. + ## [1.0.5] - 2025-12-09 ### Added diff --git a/cli/README.md b/cli/README.md index afdf27f..4de9def 100644 --- a/cli/README.md +++ b/cli/README.md @@ -13,7 +13,7 @@ ``` [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -[![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org) +[![Node.js Version](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen)](https://nodejs.org) --- @@ -25,8 +25,8 @@ GuardScan is **100% free and open source**! No credit system, no paywalls, no su - βœ… **Unlimited static analysis** - 9 security scanners + code quality tools - βœ… **AI-enhanced code review** - Bring your own API key (OpenAI, Claude, Gemini, Ollama) -- βœ… **Works fully offline** - No internet required for static analysis -- βœ… **Privacy-first** - Never uploads your source code +- βœ… **Offline-first static analysis** - Local scanners and SBOM inventory run without internet; CVEs can reuse a fresh matching snapshot +- βœ… **Privacy-first** - GuardScan does not upload your source code to GuardScan servers - βœ… **No usage limits** - Scan unlimited LOC, unlimited repositories --- @@ -40,9 +40,13 @@ npm install -g guardscan # Initialize GuardScan guardscan init -# Run comprehensive security scan (100% FREE, offline) +# Run comprehensive security scan (100% FREE, offline-first) guardscan security +# Audit exact dependency versions against OSV (online) +guardscan config --offline=false +guardscan vuln . + # Configure AI provider for enhanced review (optional, BYOK) guardscan config @@ -57,12 +61,12 @@ guardscan status ## πŸ“‹ Core Features -### πŸ”’ Security Scanning (FREE, Offline) +### πŸ”’ Security Scanning (FREE, Offline-First) GuardScan includes **comprehensive security scanners**: 1. **Secrets Detection** - Find hardcoded API keys, passwords, tokens (20+ patterns) -2. **Dependency Vulnerabilities** - Scan npm, pip, Maven, Cargo dependencies +2. **Dependency Vulnerabilities** - Scan exact npm, PyPI, Go, RubyGems, Cargo, and Maven versions with OSV; reuse fresh snapshots offline 3. **OWASP Top 10** - SQL injection, XSS, insecure configs, CSRF, XXE 4. **Docker Security** - Dockerfile and container scanning 5. **Infrastructure as Code** - Terraform, CloudFormation, Kubernetes security @@ -134,7 +138,7 @@ All commands are **100% FREE** with no limits! | Command | Description | | ------------------ | ------------------------------------- | -| `guardscan init` | Initialize config, generate client_id | +| `guardscan init` | Initialize local configuration | | `guardscan config` | Configure AI provider & settings | | `guardscan status` | Show configuration and repo info | | `guardscan reset` | Clear local cache & config | @@ -143,9 +147,27 @@ All commands are **100% FREE** with no limits! | Command | Description | | -------------------- | ----------------------------------------- | -| `guardscan security` | Run comprehensive security scan (offline) | -| `guardscan scan` | Quick security scan | -| `guardscan run` | AI-enhanced full code review (BYOK) | +| `guardscan security` | Run comprehensive security scanning | +| `guardscan scan` | Static-safe security, quality, and SBOM scan | +| `guardscan vuln` | Audit exact dependency versions with OSV | +| `guardscan run` | Required local review with optional AI enrichment | + +Dependency scanning details, offline snapshots, severity limitations, and CI examples are documented in [Dependency Vulnerability Scanning](../docs/VULNERABILITY_SCANNING.md). + +### CI output and exit codes + +```bash +guardscan security --ci --format json --output guardscan.json --fail-on high +guardscan security --format sarif --output guardscan.sarif +``` + +Structured scan output uses the `guardscan.scan.v1` envelope and includes security, quality, SBOM, AI, scanner status, errors, and policy state. Exit code `0` means the run and policy passed, `1` means findings violated policy, and `2` means scanner coverage or execution failed. `--allow-partial` is an explicit relaxation for incomplete coverage. + +`guardscan scan` does not execute repository-controlled tests or linters by default. Use +`--run-project-code` only for a trusted repository; reports record whether they are +`static-analysis` or `project-code-executed`. Child processes receive a scrubbed +environment and isolated home. `--isolate-project-network` additionally requests an +OS-backed network sandbox and fails the affected checks if the platform sandbox is unavailable. ### Testing & Quality Commands @@ -159,8 +181,10 @@ All commands are **100% FREE** with no limits! | Command | Description | | ----------------- | ----------------------------------- | -| `guardscan sbom` | Generate Software Bill of Materials | +| `guardscan sbom` | Generate schema-valid SPDX 2.3 or CycloneDX 1.7 | | `guardscan rules` | Custom YAML-based rule engine | +| `guardscan cache` | Inspect or clear local AI caches | +| `guardscan telemetry` | Inspect, sync, or clear opt-in telemetry | ### AI-Powered Commands (BYOK) @@ -182,7 +206,7 @@ All commands are **100% FREE** with no limits! We take privacy seriously: -### ❌ Never Stored or Transmitted +### ❌ Never Sent to GuardScan Servers - Your source code - File paths or file names @@ -190,25 +214,32 @@ We take privacy seriously: - API keys or secrets - Proprietary information -### βœ… Optional Telemetry (Anonymized) +Local AI/RAG features can store prompts, responses, file paths, and code-derived snippets in your machine's `~/.guardscan/cache` for caching and retrieval. Use `guardscan cache clear --repo --force` or `guardscan cache clear --all --force` to remove cached source-derived data. + +### βœ… Opt-In Aggregate Telemetry - Command usage (e.g., "security" command ran) - Execution duration - LOC count (aggregate number only) -- AI model used (e.g., "gpt-4") +- Coarse execution mode (static, local AI, or cloud AI) **Telemetry is:** -- Optional (easily disabled: `guardscan config --telemetry=false`) -- Completely anonymized -- Only used to improve GuardScan -- Never sold or shared +- Disabled by default and explicitly enabled with `guardscan config --telemetry=true` +- Queued locally only after consent +- Sent only by `guardscan telemetry sync` to an endpoint you configure +- Recording and delivery are suppressed by offline mode or `--no-telemetry` +- Strictly allowlisted; malformed or unknown persisted fields are quarantined locally and never sent +- Retained through bounded maintenance (newest 1,000 events, 30 days, 20 quarantine artifacts) +- Serialized across processes during explicit synchronization + +See the [Privacy Policy](../PRIVACY.md) for the exact event allowlist and local retention behavior. --- ## 🎯 How It Works -### Static Analysis (Offline, No AI) +### Static Analysis (Offline-First, No AI) ```bash guardscan security @@ -218,7 +249,7 @@ Runs **9 security scanners** locally: - Scans your codebase - Generates markdown report -- **100% offline** - no internet needed +- **Offline-first** - local scanners and local SBOM inventory run without internet; CVE results require either OSV access or a fresh matching snapshot - **100% free** - no limits ### AI-Enhanced Review (Your API Key) @@ -235,10 +266,11 @@ guardscan run How it works: -1. GuardScan analyzes your code locally -2. Sends anonymized context to **your AI provider** (using **your API key**) -3. AI provides insights and suggestions -4. Report saved locally +1. GuardScan runs its required local security and quality analysis. +2. If configured, it sends selected source-derived context to **your AI provider** using **your API key**. +3. AI findings enrich the local report; they never replace scanner coverage. +4. Incomplete required coverage is recorded in the report and exits with code `2`. +5. The report is saved locally. **You pay your AI provider directly** - GuardScan is free! @@ -252,18 +284,12 @@ No credit system. No subscriptions. No paywalls. ### AI Providers (If You Use AI Features) -**You pay them directly (not GuardScan):** - -- **OpenAI GPT-4**: ~$0.01-0.03 per 1K tokens -- **Claude Sonnet**: ~$0.003 per 1K tokens -- **Gemini Pro**: Free tier available -- **Ollama**: 100% free (runs locally) - -**Example costs for 10K LOC codebase:** +**You pay providers directly (not GuardScan):** -- Static analysis only: **$0** -- With OpenAI GPT-4: **~$2-5** (paid to OpenAI) -- With Ollama (local): **$0** +- Cloud providers bill through your own account and pricing plan. +- Ollama and LM Studio are local in offline mode only at literal `127/8` or `::1` endpoints. + Remote self-hosted endpoints require online mode and `allowRemoteSelfHosted: true`. +- Static analysis only does not require an AI provider. --- @@ -278,7 +304,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ GuardScan CLI (Node.js/TypeScript) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ β€’ 21 Commands (security, run, test, explain...) β”‚ β”‚ +β”‚ β”‚ β€’ 28 Commands (security, vuln, run, test...) β”‚ β”‚ β”‚ β”‚ β€’ 30 Core Modules (scanners, parsers, metrics) β”‚ β”‚ β”‚ β”‚ β€’ 9 AI Features (explain, review, test-gen, etc.) β”‚ β”‚ β”‚ β”‚ β€’ 7 Language Parsers (Python, Java, Go, Rust...) β”‚ β”‚ @@ -288,7 +314,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a β”‚ β”‚ Cache: ~/.guardscan/cache/ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ Optional telemetry only β”‚ +β”‚ β”‚ Explicit network actions only β”‚ β”‚ β–Ό β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ @@ -296,35 +322,34 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a β”‚ β”‚ β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ User's AI Provider β”‚ β”‚ GuardScan Backend β”‚ -β”‚ (User pays directly) β”‚ β”‚ (Optional telemetry) β”‚ +β”‚ User's AI Provider β”‚ β”‚ Configured HTTPS β”‚ +β”‚ (User pays directly) β”‚ β”‚ telemetry endpoint β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β€’ OpenAI (GPT-4) β”‚ β”‚ Cloudflare Workers β”‚ -β”‚ β€’ Anthropic (Claude) β”‚ β”‚ + Supabase β”‚ -β”‚ β€’ Google (Gemini) β”‚ β”‚ β”‚ -β”‚ β€’ Ollama (Local) β”‚ β”‚ β€’ Health checks β”‚ -β”‚ β”‚ β”‚ β€’ Anonymous telemetry β”‚ +β”‚ β€’ OpenAI β”‚ β”‚ β€’ Explicit sync only β”‚ +β”‚ β€’ Anthropic β”‚ β”‚ β€’ Aggregate allowlist β”‚ +β”‚ β€’ Google Gemini β”‚ β”‚ β€’ No default endpoint β”‚ +β”‚ β€’ Ollama/LM Studio β”‚ β”‚ β€’ No source or findings β”‚ β”‚ User's API Key β†’ β”‚ β”‚ β€’ NO source code β”‚ -β”‚ User's billing β†’ β”‚ β”‚ β€’ NO credit validation β”‚ +β”‚ User controls billing β”‚ β”‚ β€’ Optional telemetry β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ### Technology Stack -**CLI (34,213 LOC):** +**CLI:** - Language: TypeScript 5.3+ (strict mode) -- Runtime: Node.js 18+ +- Runtime: Node.js 22+ - Framework: Commander.js -- Testing: Jest (70%+ coverage) +- Testing: Jest with enforced coverage thresholds - Build: TypeScript Compiler (tsc) -**Backend (913 LOC - Optional):** +**Telemetry collector (optional):** -- Platform: Cloudflare Workers (serverless) -- Database: Supabase PostgreSQL (optional) -- Purpose: Anonymous telemetry only -- Cost: $0-5/month (Cloudflare free tier) +- The CLI has no hosted endpoint configured by default. +- Operators can deploy any compatible HTTPS collector. +- Delivery occurs only through `guardscan telemetry sync` after setting `GUARDSCAN_TELEMETRY_URL`. +- The payload is the strict aggregate event allowlist described in [PRIVACY.md](../PRIVACY.md); errors and findings are excluded. --- @@ -380,17 +405,16 @@ GuardScan is **open source** and we welcome contributions! - **Report bugs**: [GitHub Issues](https://github.com/ntanwir10/GuardScan/issues) - **Request features**: [GitHub Issues](https://github.com/ntanwir10/GuardScan/issues) -- **Submit PRs**: See [CONTRIBUTING.md](docs/CONTRIBUTING.md) +- **Submit PRs**: Open a focused pull request with tests and a clear rationale --- ## πŸ“š Documentation -- [Installation Guide](docs/GETTING_STARTED.md) -- [Configuration Guide](docs/CONFIGURATION.md) -- [API Documentation](docs/API.md) -- [Security Scanners](docs/SECURITY_SCANNERS.md) -- [Contributing Guidelines](docs/CONTRIBUTING.md) +- [Installation Guide](../docs/GETTING_STARTED.md) +- [AI Features](../docs/AI_QUICK_REFERENCE.md) +- [API Documentation](../docs/API.md) +- [Dependency Vulnerability Scanning](../docs/VULNERABILITY_SCANNING.md) --- @@ -409,10 +433,10 @@ A: Only if you want AI-enhanced review. Static analysis (9 security scanners) wo A: Your choice! OpenAI (powerful), Claude (balanced), Gemini (affordable), Ollama (free, local). **Q: Does GuardScan upload my code?** -A: **Never**. GuardScan only uploads anonymized metadata for optional telemetry. +A: GuardScan does not upload source code to GuardScan servers. If you enable AI features, prompts are sent directly to your configured provider; local caches may store prompts/responses on your machine. **Q: Can I disable telemetry?** -A: Yes! Run `guardscan config --telemetry=false` or set `telemetryEnabled: false` in `~/.guardscan/config.yml`. +A: Yes. Telemetry is disabled by default. `guardscan config --telemetry=false` persists the setting and deletes queued events, while `--no-telemetry` suppresses one invocation. **Q: How do I support this project?** A: Star the repo on GitHub, contribute code, report bugs, or sponsor the project! @@ -432,8 +456,7 @@ GuardScan is built with these amazing open-source tools: - [Commander.js](https://github.com/tj/commander.js) - CLI framework - [Chalk](https://github.com/chalk/chalk) - Terminal styling - [Axios](https://github.com/axios/axios) - HTTP client -- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless backend -- [Supabase](https://supabase.com/) - Open-source Firebase alternative +- [OSV](https://osv.dev/) - Open vulnerability data and package-version queries --- diff --git a/cli/docs/PERFORMANCE.md b/cli/docs/PERFORMANCE.md index 70c3be8..f1bda84 100644 --- a/cli/docs/PERFORMANCE.md +++ b/cli/docs/PERFORMANCE.md @@ -190,7 +190,7 @@ guardscan config **Solution**: -- Use `--no-cloud` flag to skip cloud dependency checks +- Enable `offlineMode` in config to skip online dependency and license advisory checks - Cache dependency results - Limit to production dependencies only diff --git a/cli/docs/TESTING_TOOLS.md b/cli/docs/TESTING_TOOLS.md new file mode 100644 index 0000000..10385ab --- /dev/null +++ b/cli/docs/TESTING_TOOLS.md @@ -0,0 +1,19 @@ +# Testing Tools Guide + +GuardScan's `perf` and `mutation` commands integrate with optional external tools. These tools are not bundled with GuardScan. + +## Performance + +- `guardscan perf --load` and `guardscan perf --stress` require `k6`. +- `guardscan perf --web ` requires Lighthouse. +- Install k6 from . +- Install Lighthouse with `npm install -g lighthouse`. + +## Mutation Testing + +- JavaScript and TypeScript mutation testing uses Stryker when available. +- Install Stryker with `npm install --save-dev @stryker-mutator/core`. + +## Offline Behavior + +These tools run locally, but the target application or audited URL may require network access. GuardScan passes URLs and generated script paths as process arguments rather than shell-interpolated strings. diff --git a/docs/AI_QUICK_REFERENCE.md b/docs/AI_QUICK_REFERENCE.md index 681e08c..282af3f 100644 --- a/docs/AI_QUICK_REFERENCE.md +++ b/docs/AI_QUICK_REFERENCE.md @@ -1,192 +1,73 @@ # AI Quick Reference -## Essential Commands +GuardScan is BYOK: configure a cloud provider API key or a local provider, then run AI-assisted commands. Advanced nested settings are edited directly in `~/.guardscan/config.yml`. -### Setup +## Setup ```bash -# View available models -guardscan models list - -# Get model info -guardscan models info gpt-4o - -# Set your model -guardscan config set model gpt-4o +guardscan config +guardscan config --provider openai --key "$OPENAI_API_KEY" +guardscan config --provider ollama +guardscan config --telemetry=false +guardscan config --offline=true ``` -### Model Routing +## Core AI Workflows ```bash -# List current routing -guardscan routing list - -# Set task-specific model -guardscan routing set code-review --model gpt-4o -guardscan routing set chat --priority speed - -# Test routing -guardscan routing test code-review +guardscan run --with-ai +guardscan review --base main +guardscan chat +guardscan explain src/index.ts --type file +guardscan test-gen --file src/index.ts +guardscan docs --type architecture ``` -### Budget Management +## Model And Budget Tools ```bash -# Check budget status +guardscan models list +guardscan models info gpt-4o +guardscan routing list +guardscan routing set code-review --model gpt-4o guardscan budget status - -# Set budget limits guardscan budget set --daily 10 --monthly 100 - -# View usage report -guardscan budget report --days 30 -``` - -### Monitoring - -```bash -# View metrics guardscan metrics show --days 7 - -# Check cache performance guardscan cache stats - -# Export metrics -guardscan metrics export --output metrics.json -``` - -## Configuration Cheatsheet - -### Enable All Features (Recommended) - -```bash -guardscan config set retry.enabled true -guardscan config set cache.enabled true -guardscan config set circuitBreaker.enabled true -guardscan config set observability.enabled true -guardscan config set modelRouting.enabled true -guardscan budget set --daily 10 --monthly 100 +guardscan cache clear --repo --force ``` -### Cost-Optimized - -```bash -guardscan config set cache.enabled true -guardscan config set cache.semanticThreshold 0.9 -guardscan config set modelRouting.enabled true -guardscan config set modelRouting.strategy cost -guardscan routing set chat --model gpt-4.1-mini +## Advanced Settings + +Edit `~/.guardscan/config.yml` for nested settings: + +```yaml +cache: + enabled: true + semanticThreshold: 0.95 + maxSizeMB: 100 + ttlSeconds: 3600 +modelRouting: + enabled: true + strategy: balanced +observability: + enabled: true ``` -### Quality-First +## CI Mode ```bash -guardscan config set modelRouting.strategy quality -guardscan routing set code-review --model gpt-4o -guardscan routing set explanation --model claude-sonnet-4.5 +guardscan --no-telemetry scan --ci --offline --format json --output guardscan-results.json --fail-on critical +guardscan --no-telemetry security --ci --format sarif --output guardscan.sarif --max-findings 50 ``` -### Development (Free) - -```bash -guardscan config set provider ollama -guardscan config set model codellama -# All requests free, local, private -``` - -## Feature Quick Reference - -| Feature | Enable With | Check With | -| --------------- | -------------------------------------------------- | ------------------------- | -| Retry | `guardscan config set retry.enabled true` | `guardscan metrics show` | -| Caching | `guardscan config set cache.enabled true` | `guardscan cache stats` | -| Circuit Breaker | `guardscan config set circuitBreaker.enabled true` | `guardscan metrics show` | -| Rate Limiting | `guardscan config set rateLimit.enabled true` | Check wait times | -| Observability | `guardscan config set observability.enabled true` | `guardscan metrics show` | -| Model Routing | `guardscan config set modelRouting.enabled true` | `guardscan routing list` | -| Budgets | `guardscan budget set --daily ` | `guardscan budget status` | - -## Troubleshooting - -| Problem | Solution | -| -------------------- | ------------------------------------------------------------------- | -| Budget exceeded | `guardscan budget set --daily ` | -| Circuit breaker open | Wait 60s or check provider status | -| Low cache hit rate | Lower threshold: `guardscan config set cache.semanticThreshold 0.9` | -| High costs | Enable caching + routing with cost strategy | -| Slow performance | Use faster models: `guardscan routing set chat --priority speed` | -| Rate limit errors | Enable rate limiting: `guardscan config set rateLimit.enabled true` | - -## Cost Optimization Quick Wins - -1. **Enable caching** (30-50% savings): - ```bash - guardscan config set cache.enabled true - ``` - -2. **Use cheap models for simple tasks**: - ```bash - guardscan routing set chat --model gpt-4.1-mini - ``` - -3. **Enable smart routing**: - ```bash - guardscan config set modelRouting.enabled true - guardscan config set modelRouting.strategy cost - ``` - -4. **Monitor and optimize**: - ```bash - guardscan budget report --days 7 - guardscan cache stats - ``` - -## Model Comparison - -### By Cost (Per 1M tokens) - -| Model | Input | Output | Best For | -| --------------------- | ------ | ------- | -------------------------- | -| gpt-4.1-mini | $0.15 | $0.60 | Chat, simple tasks | -| gemini-2.5-flash-lite | $37.50 | $150 | Fast, balanced tasks | -| gemini-2.5-flash | $75 | $300 | Code generation | -| gpt-4o | $2,500 | $10,000 | Code review, quality tasks | -| claude-sonnet-4.5 | $3,000 | $15,000 | Reasoning, explanation | - -### By Context Window - -| Model | Context | Best For | -| -------------------- | ----------- | ------------------ | -| gemini-3-pro | 2M tokens | Large codebases | -| gemini-2.5-pro/flash | 1M tokens | Large files | -| gpt-4o | 128k tokens | Most use cases | -| claude models | 200k tokens | Long conversations | - -## Default Routing - -| Task | Priority | Typical Selection | -| --------------- | -------- | ------------------------------- | -| code-review | quality | gpt-4o, claude-sonnet-4.5 | -| code-generation | balanced | gemini-2.5-flash, gpt-4o | -| chat | speed | gpt-4.1-mini, gemini-flash-lite | -| explanation | quality | claude models, gpt-4o | -| refactoring | balanced | gpt-4o, gemini-2.5-flash | -| test-generation | balanced | gemini-2.5-flash, gpt-4o | - -## Performance Targets - -| Metric | Target | Command | -| -------------- | ------------- | ------------------------- | -| Success Rate | >99.5% | `guardscan metrics show` | -| Cache Hit Rate | >40% | `guardscan cache stats` | -| P95 Latency | <2000ms | `guardscan metrics show` | -| Daily Budget | Within limits | `guardscan budget status` | +## Privacy Controls -## Support +- `--no-telemetry` disables analytics for one command. +- `--no-cache` disables AI response caching for one command. +- `guardscan config --telemetry=false` persists telemetry opt-out and deletes queued events. +- `guardscan config --offline=true` blocks cloud AI, cloud embeddings, advisory lookups, update checks, and telemetry recording and delivery. +- `guardscan telemetry status` shows consent and local queue state; `guardscan telemetry sync` is the only delivery action. +- `guardscan cache clear --repo --force` clears the current repository, while `--all` clears every repository cache. -- **Full Documentation**: See `docs/` folder -- **Architecture**: [AI_ARCHITECTURE.md](./AI_ARCHITECTURE.md) -- **Reliability**: [AI_RELIABILITY.md](./AI_RELIABILITY.md) -- **Cost**: [COST_OPTIMIZATION.md](./COST_OPTIMIZATION.md) -- **Models**: [MODEL_MANAGEMENT.md](./MODEL_MANAGEMENT.md) -- **Migration**: [MIGRATION_ENHANCED_AI.md](./MIGRATION_ENHANCED_AI.md) +Telemetry is disabled by default. If enabled, it queues an aggregate allowlisted event locally while online; it never includes source, paths, prompts, responses, findings, or errors. diff --git a/docs/API.md b/docs/API.md index c12cc4a..9bb0b43 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,280 +1,65 @@ # API Documentation -Backend API documentation for GuardScan. +GuardScan is local-first and BYOK. Built-in scanners do not send source code to GuardScan services. Cloud AI requests go directly to the provider selected by the user. -## Base URL +## Optional telemetry -``` -Production: https://api.guardscancli.com -Development: http://localhost:8787 -``` - -## Authentication - -Currently, authentication is done via `client_id` passed in request bodies. No API keys required for basic usage. - -## Endpoints - -### Health Check - -Check API status. - -**Endpoint:** `GET /health` - -**Response:** - -```json -{ - "status": "ok", - "timestamp": "2024-01-15T10:30:00Z" -} -``` - ---- - -### Validate Credits - -Validate if client has sufficient credits for a review. - -**Endpoint:** `POST /api/validate` - -**Request Body:** - -```json -{ - "clientId": "uuid-here", - "repoId": "hashed-repo-id", - "locCount": 1250 -} -``` +Telemetry is anonymous, opt-in, and delivered only by an explicit `guardscan telemetry sync`. Recording and delivery are suppressed when telemetry consent is disabled, persistent offline mode is enabled, `GUARDSCAN_OFFLINE=true`, `GUARDSCAN_NO_TELEMETRY=true`, `--offline`, or `--no-telemetry` applies. -**Response:** +### Endpoint -```json -{ - "allowed": true, - "remainingLoc": 8750 -} +```text +POST /api/telemetry ``` -**Status Codes:** -- `200`: Success -- `400`: Invalid request -- `500`: Server error - ---- - -### Submit Telemetry - -Submit anonymized telemetry data. - -**Endpoint:** `POST /api/telemetry` - -**Request Body:** +### Request: `guardscan.telemetry.v1` ```json { - "clientId": "uuid-here", - "repoId": "hashed-repo-id", + "schemaVersion": "guardscan.telemetry.v1", + "batchId": "00000000-0000-4000-8000-000000000001", + "sentAt": 1705320001000, + "cliVersion": "1.1.0", "events": [ { + "eventId": "00000000-0000-4000-8000-000000000002", "action": "review", "loc": 350, "durationMs": 2100, - "model": "gpt-4", - "timestamp": 1705320000000 + "executionMode": "cloud-ai", + "occurredAt": 1705320000000 } ] } ``` -**Response:** - -```json -{ - "status": "ok" -} -``` - -**Status Codes:** -- `200`: Success -- `400`: Invalid request -- `500`: Server error - ---- - -### Get Credits - -Get remaining credit balance for a client. - -**Endpoint:** `GET /api/credits/:clientId` - -**Parameters:** -- `clientId` (path): Client UUID - -**Response:** - -```json -{ - "clientId": "uuid-here", - "remainingLoc": 5000, - "plan": "tier_2" -} -``` - -**Status Codes:** -- `200`: Success -- `404`: Client not found -- `500`: Server error - ---- - -### Stripe Webhook - -Handle Stripe payment webhooks. - -**Endpoint:** `POST /api/stripe-webhook` - -**Headers:** -- `stripe-signature`: Webhook signature for verification - -**Events Handled:** -- `checkout.session.completed`: Credit purchase completed -- `invoice.payment_failed`: Payment failed - -**Response:** - -```json -{ - "received": true -} -``` - -**Status Codes:** -- `200`: Success -- `400`: Invalid signature -- `500`: Server error - ---- - -## Rate Limiting - -Currently no rate limiting is implemented. Consider adding rate limiting in production: +The payload never includes installation or repository identifiers, source, paths, prompts, responses, findings, model names, errors, dependency names, or arbitrary metadata. -- 100 requests per minute per client_id -- 1000 requests per hour per IP - -## Error Responses - -All errors return JSON in this format: +### Response ```json { - "error": "Error message here" + "status": "accepted", + "batchId": "00000000-0000-4000-8000-000000000001", + "accepted": 1, + "acceptedEventIds": ["00000000-0000-4000-8000-000000000002"] } ``` -## CORS - -CORS is enabled for all origins (`*`). Restrict in production if needed. - -## Webhook Security - -### Stripe Webhooks - -Stripe webhooks are verified using the signature from the `stripe-signature` header. Always verify signatures before processing events. - -Example verification: - -```typescript -const signature = request.headers.get('stripe-signature'); -const event = stripe.webhooks.constructEvent( - body, - signature, - webhookSecret -); -``` - -## Data Privacy - -- No source code is transmitted or stored -- Repository IDs are cryptographically hashed -- Client IDs are UUIDs with no PII -- Telemetry is anonymized and aggregated - -## Client Libraries - -### JavaScript/TypeScript - -```typescript -import { APIClient } from 'guardscan'; - -const client = new APIClient('https://api.guardscancli.com'); - -// Validate credits -const validation = await client.validate({ - clientId: 'uuid', - repoId: 'hash', - locCount: 1000, -}); - -// Get credits -const credits = await client.getCredits('uuid'); - -// Send telemetry -await client.sendTelemetry({ - clientId: 'uuid', - repoId: 'hash', - events: [...], -}); -``` - -### cURL Examples - -**Validate Credits:** - -```bash -curl -X POST https://api.guardscancli.com/api/validate \ - -H "Content-Type: application/json" \ - -d '{ - "clientId": "uuid", - "repoId": "hash", - "locCount": 1000 - }' -``` - -**Get Credits:** +`status` is `accepted` or `duplicate`. `eventId` is the canonical idempotency +key across batches. A partial `accepted` acknowledgement must list exactly the +accepted requested IDs. A `duplicate` acknowledgement is valid only when it +covers the complete batch; when IDs are included they must equal the complete +requested set. Inconsistent, unknown, failed, or unacknowledged events stay +local. -```bash -curl https://api.guardscancli.com/api/credits/uuid -``` +## Local CLI output -**Submit Telemetry:** +Use CI mode for machine-readable scan output: ```bash -curl -X POST https://api.guardscancli.com/api/telemetry \ - -H "Content-Type: application/json" \ - -d '{ - "clientId": "uuid", - "repoId": "hash", - "events": [ - { - "action": "review", - "loc": 350, - "durationMs": 2100, - "model": "gpt-4", - "timestamp": 1705320000000 - } - ] - }' +guardscan --no-telemetry scan --ci --offline --format json --output guardscan-results.json +guardscan --no-telemetry security --ci --format sarif --output guardscan.sarif ``` -## Status Monitoring - -Monitor API status at: https://status.guardscancli.com (if implemented) - -## Support - -For API issues or questions: -- GitHub Issues: https://github.com/ntanwir10/GuardScan/issues -- Email: api-support@guardscancli.com +The JSON report schema is `guardscan.scan.v1`. SARIF output uses SARIF 2.1.0. diff --git a/docs/DOCKER_GUIDE.md b/docs/DOCKER_GUIDE.md index d92b139..a98cb9f 100644 --- a/docs/DOCKER_GUIDE.md +++ b/docs/DOCKER_GUIDE.md @@ -49,7 +49,7 @@ This comprehensive guide covers running GuardScan CLI in Docker across all major ### General Requirements - Docker Engine 20.10+ or Docker Desktop 4.0+ -- Node.js 18+ (in container) +- Node.js 22+ (in container) - 2GB+ RAM available for Docker - Internet connection (for npm install and AI features) @@ -926,9 +926,13 @@ export GUARDSCAN_API_URL=https://custom-api.example.com #### `GUARDSCAN_NO_TELEMETRY` -Disable telemetry for the current command execution (set via `--no-telemetry` flag). +Disable telemetry for the current command execution with either the environment +variable or the `--no-telemetry` flag. -**Note:** This is handled via the `--no-telemetry` CLI flag, not an environment variable. +```bash +export GUARDSCAN_NO_TELEMETRY=true +guardscan security +``` ### OS-Specific Environment Variables @@ -1237,7 +1241,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v3 with: - node-version: '18' + node-version: '22' - name: Install GuardScan run: npm install -g guardscan @@ -1266,7 +1270,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v3 with: - node-version: '18' + node-version: '22' - name: Install GuardScan run: npm install -g guardscan @@ -1741,9 +1745,14 @@ apk add --no-cache \ ```bash mkdir -p /tmp/guardscan - chmod 755 /tmp/guardscan + chmod 700 /tmp/guardscan ``` + `GUARDSCAN_HOME` must be an absolute directory private to the container + user. Do not share the same writable state directory between different + users or containers. Mount a user-owned volume when state must survive the + container lifecycle. + ### General Troubleshooting #### Container Exits Immediately diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 1c4feab..9f9fbb1 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -12,7 +12,7 @@ GuardScan is a privacy-first CLI tool that uses AI to automatically review your ## Key Features -- **Privacy-First**: Never uploads source code, only anonymized metadata +- **Privacy-First**: Built-in scanners stay local; cloud AI receives context only when you configure and invoke it - **Multi-Provider**: Supports OpenAI, Claude, Gemini, Ollama, and more - **Offline-Capable**: Works without internet using local AI models - **Universal**: Works with any git-based repository @@ -45,7 +45,8 @@ cd your-project guardscan init ``` -This generates a unique `client_id` stored locally in `~/.guardscan/config.yml`. +This creates local configuration under `~/.guardscan/config.yml`. +New non-interactive configurations default to offline mode with telemetry disabled. ### 2. Configure AI Provider @@ -69,9 +70,8 @@ guardscan run This will: 1. Count lines of code -2. Validate credits (if online) -3. Analyze your codebase with AI -4. Generate a detailed report +2. Analyze your codebase with the configured AI provider +3. Generate a detailed report ### 4. Check Your Status @@ -79,7 +79,7 @@ This will: guardscan status ``` -View your configuration, repository info, and remaining credits. +View your configuration, repository info, and local status. ## Using Local AI (Offline) @@ -103,7 +103,7 @@ guardscan config 4. Run offline: ```bash -guardscan run --no-cloud +guardscan run ``` ### With LM Studio @@ -115,7 +115,7 @@ guardscan run --no-cloud ```bash guardscan config # Select "lmstudio" as provider -# Default endpoint: http://localhost:1234 +# Endpoint may be entered as http://localhost:1234; GuardScan normalizes the OpenAI-compatible /v1 base ``` ## Security Scanning @@ -126,6 +126,17 @@ Run a free security scan: guardscan security ``` +For a guaranteed local-only run, disable CVE lookup or prepare an offline snapshot first: + +```bash +guardscan security --offline --no-cve + +# Prepare exact-version CVE coverage, then reuse it offline +guardscan config --offline=false +guardscan vuln db update . +guardscan vuln . --offline +``` + For verbose debug output, use the `--debug` flag: ```bash @@ -141,6 +152,8 @@ This performs SAST-like scanning for: - Code injection risks - And more... +See [Dependency Vulnerability Scanning](./VULNERABILITY_SCANNING.md) for supported ecosystems, snapshot freshness, OSV/CVSS/CISA limitations, and CI thresholds. + ## Review Specific Files Target specific files or patterns: @@ -202,11 +215,12 @@ guardscan run > review.md ```bash # Add to .git/hooks/pre-commit #!/bin/bash -guardscan security --no-cloud -if [ $? -ne 0 ]; then - echo "Security issues found! Review before committing." - exit 1 -fi +guardscan security --offline --no-cve --ci --format json --output guardscan.json +case $? in + 0) exit 0 ;; + 1) echo "GuardScan policy failed; review guardscan.json." ; exit 1 ;; + 2) echo "GuardScan could not complete required coverage." ; exit 2 ;; +esac ``` ### CI/CD Integration @@ -216,9 +230,9 @@ fi - name: Run GuardScan run: | npm install -g guardscan - guardscan init - guardscan config --provider openai --key ${{ secrets.OPENAI_API_KEY }} - guardscan run --no-cloud + guardscan --no-telemetry init + guardscan security --offline --no-cve --ci \ + --format sarif --output guardscan.sarif --fail-on high ``` ## Command Flags and Options @@ -230,7 +244,10 @@ GuardScan commands support various flags to customize behavior. Flags use kebab- - **File Selection**: `-f, --files ` - Specify files or patterns to analyze - **Debug Mode**: `--debug` - Enable verbose debug logging (available for `security` command) - **Output**: `-o, --output ` - Specify output file path -- **Negated Flags**: Flags like `--no-body` or `--no-cloud` disable features +- **Offline boundary**: `--offline` - Block GuardScan cloud/advisory/telemetry clients for the invocation +- **Cache boundary**: `--no-cache` - Disable exact, semantic, and advisory cache reads and writes +- **Scanner completeness**: `--allow-partial` - Explicitly accept incomplete scanner coverage +- **Negated Flags**: Flags like `--no-body` disable features ### Examples @@ -248,6 +265,17 @@ guardscan commit --no-body guardscan run --no-with-ai ``` +### CI results + +`scan` and `security` support versioned JSON and SARIF output: + +```bash +guardscan security --ci --format json --output guardscan.json --fail-on high +guardscan scan --ci --format sarif --output guardscan.sarif --max-findings 25 +``` + +Exit code `0` means execution and policy passed, `1` means findings violated policy, and `2` means a required scanner or coverage step failed. JSON includes the same policy exit code and each scanner's succeeded, failed, or skipped state. + ### Flag Naming Convention - CLI flags use **kebab-case**: `--with-ai`, `--test-command`, `--embedding-provider` @@ -293,34 +321,34 @@ guardscan security --debug Edit `~/.guardscan/config.yml`: ```yaml -clientId: your-uuid provider: openai apiKey: sk-... -telemetryEnabled: true -offlineMode: false +telemetryEnabled: false +offlineMode: true createdAt: '2024-01-15T10:00:00Z' lastUsed: '2024-01-15T15:30:00Z' ``` ## Privacy & Telemetry -### What is Collected? +### What is queued after opt-in? -Only anonymized metadata: +Only a strict aggregate allowlist: -- Hashed repository ID -- Lines of code count -- AI provider used +- Random event ID +- Action category +- Aggregate lines of code - Processing duration -- Action type (review/security) +- Coarse execution mode +- Timestamp ### What is NOT Collected? - Source code -- File names -- Variable names -- Comments -- Any PII +- File names or paths +- Prompts or AI responses +- Findings, dependency names, or errors +- API keys ### Disabling Telemetry @@ -335,6 +363,8 @@ Or edit config: telemetryEnabled: false ``` +Telemetry is disabled by default and never uploads automatically. After explicit consent, events are queued only while online. Configure an HTTPS endpoint and run `guardscan telemetry sync` to deliver a batch; failed delivery leaves it queued. `guardscan config --telemetry=false` deletes the queue. Use `guardscan telemetry status` and `guardscan telemetry clear --force` to inspect or delete it explicitly. + ## Troubleshooting ### "Configuration not found" @@ -345,13 +375,9 @@ Run `guardscan init` first. Run `guardscan config` and set up your provider. -### "Insufficient credits" - -Either: +### "AI provider not configured" -- Purchase more credits online -- Use `--no-cloud` flag -- Switch to local AI provider (Ollama) +Either run `guardscan config` to set up a BYOK provider or switch to a local AI provider such as Ollama or LM Studio. ### "Could not connect to provider" @@ -399,4 +425,4 @@ done ## Next Steps - Read the [API Documentation](./API.md) -- Check [Contributing Guide](./CONTRIBUTING.md) +- Open a focused pull request with tests and a clear rationale diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md index 8d13ef9..a9d9c34 100644 --- a/docs/RATE_LIMITING.md +++ b/docs/RATE_LIMITING.md @@ -1,286 +1,31 @@ -# Rate Limiting Documentation +# Telemetry Collector Rate Limiting -## Overview +GuardScan does not ship or configure a hosted collector. `guardscan telemetry sync` sends one bounded batch to the HTTPS endpoint selected with `GUARDSCAN_TELEMETRY_URL`. -The GuardScan backend implements rate limiting to prevent abuse and ensure fair resource allocation across all users. Rate limiting is applied to all telemetry and monitoring endpoints. +The CLI sends no client or repository identifier. A collector that applies rate limits must therefore use transport-level information such as source IP, an operator-provided authentication mechanism, or aggregate endpoint limits. Collector operators must document their limits, retention, deletion, access-control, and jurisdiction policies independently of GuardScan. -## Implementation +## CLI behavior -### Algorithm +- At most `TELEMETRY_CONSTANTS.BATCH_SIZE` oldest events are sent per explicit sync. +- HTTP 408, 429, and 5xx responses are treated as retryable delivery failures. +- Failed and unacknowledged events remain in the local spool. +- GuardScan does not automatically retry or transmit in the background. +- `--offline` and `--no-telemetry` block both recording and delivery. +- `guardscan config --telemetry=false` deletes queued events. -- **Sliding Window**: Tracks requests within a time window -- **Per-Client Tracking**: Each client has independent rate limits -- **In-Memory Storage**: Lightweight, fast, edge-compatible -- **Automatic Cleanup**: Expired records are cleaned up every 5 minutes - -### Rate Limits - -| Endpoint | Limit | Window | Notes | -| ----------------------- | ------------ | -------- | ----------------------- | -| `/api/telemetry` | 100 requests | 1 minute | Per client ID | -| `/api/monitoring` | 50 requests | 1 minute | Per client ID or IP | -| `/api/monitoring/stats` | 30 requests | 1 minute | Per IP (admin endpoint) | - -## Response Headers - -All responses include rate limit information in headers: - -``` -X-RateLimit-Limit: 100 # Maximum requests allowed -X-RateLimit-Remaining: 95 # Requests remaining in window -X-RateLimit-Reset: 2024-11-17... # When the window resets (ISO 8601) -``` - -## Rate Limit Exceeded Response - -When rate limit is exceeded, the API returns a `429 Too Many Requests` response: - -```json -{ - "error": "Rate limit exceeded", - "message": "Too many requests. Please try again later.", - "retryAfter": 42, - "limit": 100 -} -``` - -**Response Headers:** - -``` -Status: 429 Too Many Requests -Retry-After: 42 # Seconds until rate limit resets -X-RateLimit-Limit: 100 -X-RateLimit-Remaining: 0 -X-RateLimit-Reset: 2024-11-17... -``` - -## Client Identification - -### Telemetry Endpoint - -- Uses `clientId` from request body -- Each CLI instance has a unique client ID (UUID) -- Stored in `~/.guardscan/config.json` - -### Monitoring Endpoint - -- Prioritizes `clientId` from usage events -- Falls back to Cloudflare's `CF-Connecting-IP` header -- Last resort: "unknown" (shared rate limit) - -### Stats Endpoint - -- Uses `CF-Connecting-IP` header (Cloudflare's edge IP detection) -- Falls back to `X-Forwarded-For` header -- Stricter limit (admin/analytics endpoint) - -## Configuration - -Rate limits can be adjusted in the monitoring repo at -[`worker/src/utils/rate-limiter.ts`](https://github.com/ntanwir10/GuardScan-Monitoring/blob/main/worker/src/utils/rate-limiter.ts): - -```typescript -export const rateLimiters = { - telemetry: new RateLimiter({ - windowMs: 60 * 1000, // 1 minute - maxRequests: 100, // Adjust this value - }), - monitoring: new RateLimiter({ - windowMs: 60 * 1000, - maxRequests: 50, // Adjust this value - }), - monitoringStats: new RateLimiter({ - windowMs: 60 * 1000, - maxRequests: 30, // Adjust this value - }), -}; -``` - -## Monitoring - -Rate limit warnings are logged: - -```typescript -console.warn(`Rate limit exceeded for client: ${clientId}`); -``` - -Check Cloudflare Workers logs for rate limiting activity: +Inspect or clear local state through the CLI rather than editing spool files: ```bash -wrangler tail +guardscan telemetry status +guardscan telemetry clear --force ``` -## Best Practices - -### For CLI Users - -1. **Batching**: CLI automatically batches telemetry (50 events per sync) -2. **Offline Mode**: Use `--offline` flag to disable telemetry completely -3. **Respect Limits**: Normal usage stays well within limits - -### For Backend Operators - -1. **Monitor Logs**: Watch for repeated rate limit violations -2. **Adjust Limits**: Tune based on actual usage patterns -3. **Consider Cloudflare**: For production, consider Cloudflare's Rate Limiting product -4. **Add Metrics**: Track rate limit hits in monitoring system - -## Advanced: Cloudflare Rate Limiting - -For production deployments, consider using [Cloudflare's Rate Limiting](https://developers.cloudflare.com/waf/rate-limiting-rules/): - -### Benefits - -- Distributed rate limiting across edge network -- More sophisticated rules (IP, headers, etc.) -- DDoS protection -- Persistent storage - -### Example Rule - -``` -(http.request.uri.path eq "/api/telemetry") and -(rate(1m) > 100) -``` - -**Action**: Block or Challenge - -### Migration Path - -1. Deploy with in-memory rate limiting (current) -2. Monitor usage patterns for 1-2 weeks -3. Configure Cloudflare rules based on data -4. Gradually shift to Cloudflare Rate Limiting -5. Keep in-memory as fallback - -## Testing - -### Manual Testing - -```bash -# Test telemetry endpoint -for i in {1..105}; do - curl -X POST https://guardscan-backend.workers.dev/api/telemetry \ - -H "Content-Type: application/json" \ - -d '{ - "clientId": "test-client-123", - "repoId": "test-repo", - "events": [{"action": "scan", "loc": 100, "durationMs": 1000, "model": "gpt-4", "timestamp": 1234567890, "metadata": {}}] - }' - echo "Request $i" -done - -# Should see 429 after 100 requests -``` - -### Load Testing - -```bash -# Install hey (HTTP load generator) -go install github.com/rakyll/hey@latest - -# Test with concurrent requests -hey -n 1000 -c 10 -m POST \ - -H "Content-Type: application/json" \ - -d '{"clientId": "load-test", "repoId": "test", "events": [...]}' \ - https://guardscan-backend.workers.dev/api/telemetry -``` - -### Unit Tests - -```typescript -import { RateLimiter } from './rate-limiter'; - -describe('RateLimiter', () => { - it('should allow requests within limit', () => { - const limiter = new RateLimiter({ windowMs: 1000, maxRequests: 5 }); - - for (let i = 0; i < 5; i++) { - const result = limiter.check('test-client'); - expect(result.allowed).toBe(true); - } - }); - - it('should block requests exceeding limit', () => { - const limiter = new RateLimiter({ windowMs: 1000, maxRequests: 5 }); - - for (let i = 0; i < 5; i++) { - limiter.check('test-client'); - } - - const result = limiter.check('test-client'); - expect(result.allowed).toBe(false); - }); - - it('should reset after window expires', async () => { - const limiter = new RateLimiter({ windowMs: 100, maxRequests: 1 }); - - limiter.check('test-client'); - const blocked = limiter.check('test-client'); - expect(blocked.allowed).toBe(false); - - await new Promise(resolve => setTimeout(resolve, 150)); - - const allowed = limiter.check('test-client'); - expect(allowed.allowed).toBe(true); - }); -}); -``` - -## Troubleshooting - -### CLI Getting Rate Limited - -**Symptoms**: `429 Too Many Requests` errors in CLI - -**Solutions**: - -1. Check if batching is working: `cat ~/.guardscan/cache/telemetry.json` -2. Enable offline mode temporarily: `guardscan scan --offline` -3. Clear local batch: `rm ~/.guardscan/cache/telemetry.json` -4. Contact support if legitimate usage is blocked - -### High Rate Limit Hits - -**Symptoms**: Many rate limit warnings in logs - -**Investigation**: - -1. Check client IDs hitting limits -2. Look for patterns (same IP, time of day) -3. Verify batch sizes aren't too large -4. Check for CLI bugs causing excessive requests - -**Actions**: - -1. Increase limits if legitimate usage -2. Block malicious IPs at Cloudflare level -3. Add authentication for verified users - -### Memory Issues - -**Symptoms**: Worker exceeding memory limits - -**Solutions**: - -1. Reduce cleanup interval (more frequent) -2. Add max entries limit -3. Implement LRU cache eviction -4. Consider external storage (KV, Durable Objects) - -## Future Enhancements - -1. **Persistent Storage**: Use Cloudflare Durable Objects for distributed rate limiting -2. **Token Bucket**: More flexible rate limiting algorithm -3. **Burst Allowance**: Allow short bursts above limit -4. **Per-User Tiers**: Different limits for different user types -5. **Analytics Dashboard**: Visualize rate limit metrics -6. **Auto-Scaling**: Increase limits based on load -7. **Exemptions**: Whitelist trusted clients - -## References +## Collector guidance -- [Cloudflare Rate Limiting](https://developers.cloudflare.com/waf/rate-limiting-rules/) -- [RFC 6585 - HTTP Status Code 429](https://tools.ietf.org/html/rfc6585) -- [Rate Limiting Headers](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers) +- Return `429 Too Many Requests` and a standard `Retry-After` header when limiting requests. +- Validate the `guardscan.telemetry.v1` schema and cap request size. +- Deduplicate canonically by `eventId` across batches; `batchId` is request correlation only. +- Return accepted event IDs for partial acknowledgements, and return `duplicate` only for a complete duplicate batch. +- Do not infer that an anonymous event identifies a stable installation or repository. +See [API Documentation](./API.md) for the exact request and acknowledgement contract. diff --git a/docs/RELEASE_AUTOMATION.md b/docs/RELEASE_AUTOMATION.md new file mode 100644 index 0000000..f926256 --- /dev/null +++ b/docs/RELEASE_AUTOMATION.md @@ -0,0 +1,190 @@ +# GuardScan release automation + +GuardScan uses one RC-first, append-only release train for npm, standalone GitHub assets, Homebrew, Scoop, WinGet, Chocolatey, and PyPI. pnpm, both Yarn generations, and Bun consume the npm package and are verified as separate install channels. + +The automation is fail-closed. A tag is an identity created by the release train, never publication authority. General CI has no tag trigger, publication permission, registry command, or GitHub release job. + +## Release invariants + +- `cli/package.json`, `cli/package-lock.json`, `cli/CHANGELOG.md`, the tag, and the exact commit agree. +- The stable Release Please PR remains at `1.1.0`. A bot-owned candidate commit derives `1.1.0-rc.1` from that exact PR head. +- Release builds use Node `22.23.1`, esbuild `0.28.1`, and postject `1.0.0-alpha.6`. +- Every public artifact is immutable and digest-bound to its source commit. +- Missing remote versions are published. Identical remote digests are accepted as retries. Different remote digests open an integrity incident and stop the train. +- Stable promotion is a machine decision after a full 24-hour window. It requires an unchanged release-PR head, fresh green canaries for every RC channel, and no open release incident. +- WinGet and Chocolatey remain `submitted` until their public catalogs accept them and a clean public installation passes. +- Rollback never mutates history or overwrites a release. It appends recovery events and prepares a forward-fix patch. + +## Workflow ownership + +| Workflow | Authority | +| --- | --- | +| `.github/workflows/ci.yml` | Required source, test, coverage, package, package-manager, audit, and five-host SEA gates. It cannot publish. | +| `.github/workflows/release-please.yml` | Maintains the stable release PR only, using a short-lived GitHub App token. | +| `.github/workflows/release-train.yml` | Derives RC commits, creates protected tags, dispatches builds/publication, reconciles every 30 minutes, promotes, rolls back, and persists release events. | +| `.github/workflows/release-build.yml` | Builds the exact npm tarball and five SEA targets, signs, notarizes, generates SPDX/CycloneDX, creates wheels, attests, archives deterministically, and aggregates the manifest/checksums. | +| `.github/workflows/release-publish.yml` | Publishes the tested handoffs through OIDC or first-party bot repositories. | +| `.github/workflows/release-canary.yml` | Runs hourly public install/invoke/uninstall canaries and polls moderated registries. | + +Release workflows deliberately do not use dependency caches. Release-critical actions are pinned to immutable commits. + +## Maintainer interface + +Run from `cli/`, or use `npm run release -- `: + +```bash +npm run release -- build +npm run release -- manifest +npm run release -- publish --channel npm +npm run release -- verify --channel npm +npm run release -- reconcile +npm run release -- promote +npm run release -- rollback +npm run release -- status +``` + +The low-level commands require explicit source, manifest, ledger, timestamp, artifact, and remote-identity arguments. `npm run release -- --help` is the canonical option reference. + +Important supporting commands: + +```bash +npm run release:validate +npm run release:plan -- --profile full +npm run release:prepare -- \ + --profile full \ + --output-dir ../release-evidence/v1.1.0-rc.1 \ + --ledger ../release-evidence/v1.1.0-rc.1/events.jsonl \ + --timestamp 2026-07-25T18:00:00.000Z \ + --idempotency-key train:v1.1.0-rc.1 +npm run release:render -- \ + --manifest ../release-evidence/v1.1.0-rc.1/release-manifest.json \ + --output-dir ../release-evidence/v1.1.0-rc.1/adapters +``` + +`advance` and the v1 mutable state schema remain temporarily available for compatibility with earlier local evidence. New automation uses only the hash-chained event ledger and materialized v2 state. + +## Artifact contracts + +Five host-native artifacts are required: + +- `linux-x64-glibc` +- `linux-arm64-glibc` +- `darwin-x64` +- `darwin-arm64` +- `windows-x64` + +The builder bundles one CommonJS program, allows only `tiktoken` and `chartjs-node-canvas` as optional externals, injects a Node SEA blob, and runs with Node and package managers absent from `PATH`. Required smoke covers: + +- exact version and help; +- offline static scanning; +- SPDX 2.3 and CycloneDX 1.7; +- telemetry-disabled status; +- safe reduced capability behavior. + +The standalone profile reports: + +```json +{ + "coreScan": true, + "sbom": true, + "chartRendering": false, + "accurateTokenCounting": false +} +``` + +Archives have normalized entry names, ordering, modes, timestamps, ownership, and compression. Inspection rejects traversal, absolute paths, links, duplicate/case-colliding entries, unexpected files, truncation, trailing data, invalid checksums, excessive entry counts, and excessive expanded size. + +Each PyPI wheel contains the exact signed executable already represented by its standalone archive. Its standard-library-only launcher verifies the embedded executable digest, forwards arguments and exit status, and uses `exec` for POSIX signal behavior. Wheel tags are derived from the actual platform: + +| Target | Wheel platform tag | +| --- | --- | +| Linux x64 glibc | `manylinux_2_28_x86_64` | +| Linux arm64 glibc | `manylinux_2_28_aarch64` | +| macOS x64 | `macosx_11_0_x86_64` | +| macOS arm64 | `macosx_11_0_arm64` | +| Windows x64 | `win_amd64` | + +## Append-only state + +Events live on the protected `release-ledger` branch under `events/vVERSION.jsonl`. Each event contains a sequence, previous-event hash, idempotency key, source identity, timestamp, payload, and its own digest. + +Materialized channel states include: + +```text +planned -> published +planned -> submitted -> accepted -> verified +any active state -> failed +published/verified -> withdrawn or superseded +``` + +Rollback is represented by `rollback_started`, `withdrawn`, and `superseded` events; no backward state mutation is needed. Integrity, security, and availability incidents use `incident_opened` and `incident_resolved`. + +The repository also contains: + +- `guardscan.release-event.v1` +- `guardscan.release-state.v2` +- `guardscan.promotion-decision.v1` +- strengthened `guardscan.release-manifest.v1` + +## RC and promotion + +Start the first candidate after provider onboarding: + +```bash +gh workflow run release-train.yml \ + -f action=candidate \ + -f version=1.1.0-rc.1 \ + -f release_pr=RELEASE_PR_NUMBER +``` + +The train: + +1. resolves the exact stable PR head; +2. creates a candidate commit containing only RC identity changes; +3. creates `v1.1.0-rc.1` with the release GitHub App; +4. builds, signs, attests, and verifies every artifact; +5. publishes npm under `next`, GitHub as a prerelease, TestPyPI then PyPI, and preview tap/bucket branches; +6. renders and validates WinGet/Chocolatey without publishing an RC; +7. records `publishedAt` and hourly canary evidence; +8. reconciles every 30 minutes. + +Promotion produces `promotion-decision.json`. A permitted decision requires at least 24 green samples per required channel, a complete 24-hour wall-clock window, a fresh last sample, the same source PR head, and no open incident. The release App then auto-merges that unchanged PR, tags its exact merge commit, rebuilds stable artifacts, and publishes `latest`. + +## Public installation contracts + +These commands become user-facing only when the ledger shows `verified` from the public production source: + +```bash +npm install -g guardscan +pnpm add -g guardscan +pnpm dlx guardscan +yarn global add guardscan +yarn dlx guardscan +bun add -g guardscan +bunx guardscan +brew install ntanwir10/tap/guardscan +scoop bucket add guardscan https://github.com/ntanwir10/scoop-bucket +scoop install guardscan +winget install --exact --id NaumanTanwir.GuardScan +choco install guardscan +pip install guardscan-cli +pipx install guardscan-cli +``` + +The npm package requires Node 22 or newer even when invoked by Bun. The standalone and wheel channels include the runtime. + +## Recovery + +Before stable promotion, any failed build, signature, digest, canary, vulnerability, or source-head check stops the train. The correction is a new `rc.N`. + +After stable publication: + +- immutable GitHub assets are retained and marked superseded; +- Homebrew/Scoop redirect to a known-good native release or remove the new listing; +- PyPI is yanked where authorized; +- npm is deprecated and moved forward through a patch; +- Chocolatey is unlisted/superseded; +- WinGet receives a corrective manifest; +- a higher patch version is prepared from selected known-good source. + +A release is complete only when every selected channel materializes as `verified`. diff --git a/docs/RELEASE_ONBOARDING.md b/docs/RELEASE_ONBOARDING.md new file mode 100644 index 0000000..dc4b3aa --- /dev/null +++ b/docs/RELEASE_ONBOARDING.md @@ -0,0 +1,87 @@ +# One-time release provider onboarding + +The repository contains the zero-touch release implementation. The following provider-owned identity and account steps must be completed once before `1.1.0-rc.1`. Automation must not fabricate or bypass them. + +## GitHub + +- Reauthenticate `gh` as `ntanwir10`. +- Create `ntanwir10/homebrew-tap` and `ntanwir10/scoop-bucket` as public repositories. +- Create `guardscan-release-bot` as a GitHub App. +- Grant the App GuardScan contents/pull-request/workflow access and contents/pull-request access on the tap and bucket. +- Store `RELEASE_APP_ID` as a repository variable and `RELEASE_APP_PRIVATE_KEY` as a secret. +- Create and protect `release-ledger`; require the App identity for writes. +- Protect `v*` tags so only the release App can create them. +- Enable immutable releases for GuardScan. +- Require the full `Release gate` status on the stable release PR. + +Create environments without manual reviewers: + +- `release-rc` +- `release-stable` +- `npm-publish` +- `pypi` +- `apple-notarization` +- `windows-signing` +- `winget` +- `chocolatey` + +Restrict them to the release workflows and protected candidate/stable tags. Fork pull requests must not receive environment secrets or OIDC tokens. + +## npm + +- Configure trusted publishing for package `guardscan`. +- Bind it exactly to `ntanwir10/GuardScan`, `.github/workflows/release-publish.yml`, and environment `npm-publish`. +- Do not retain an npm token fallback after OIDC succeeds. + +## TestPyPI and PyPI + +- Reserve `guardscan-cli`. +- Configure pending trusted publishers for both TestPyPI and PyPI. +- Bind them exactly to `ntanwir10/GuardScan`, `.github/workflows/release-publish.yml`, and environment `pypi`. + +## Apple + +Enroll the publisher and provision: + +- `APPLE_CERTIFICATE_P12` +- `APPLE_CERTIFICATE_PASSWORD` +- `APPLE_TEAM_ID` +- `APPLE_NOTARY_KEY_ID` +- `APPLE_NOTARY_ISSUER_ID` +- `APPLE_NOTARY_PRIVATE_KEY` + +Store them only in `apple-notarization`. Renewals and Apple identity revalidation remain external authority boundaries. + +## Azure Artifact Signing + +Create a Public Trust signing account/profile and GitHub OIDC federation. Configure these environment variables in `windows-signing`: + +- `AZURE_TENANT_ID` +- `AZURE_SUBSCRIPTION_ID` +- `AZURE_CLIENT_ID` +- `AZURE_SIGNING_ACCOUNT` +- `AZURE_SIGNING_PROFILE` +- `AZURE_SIGNING_ENDPOINT` + +Grant only the Artifact Signing Certificate Profile Signer role needed by the federated identity. + +## WinGet and Chocolatey + +- Accept the Microsoft CLA for the submitting identity. +- Store a narrowly scoped `WINGET_GITHUB_TOKEN` in `winget`. +- Create/validate the Chocolatey publisher account. +- Store `CHOCO_API_KEY` in `chocolatey`. + +WinGet review and Chocolatey validation, verification, VirusTotal, and moderation are external states. The ledger keeps them `submitted` until public installation passes. + +## Expiry monitoring + +Configure provider notifications for: + +- GitHub App key age and installation loss; +- Apple certificate/notary key expiry; +- Azure federation/profile health; +- Chocolatey API key validity; +- WinGet token expiry or revoked CLA status. + +No later release requires a human promotion click. Only provider-mandated identity, MFA, legal, certificate-renewal, or moderator requests remain human boundaries. diff --git a/docs/VULNERABILITY_SCANNING.md b/docs/VULNERABILITY_SCANNING.md new file mode 100644 index 0000000..e90afc5 --- /dev/null +++ b/docs/VULNERABILITY_SCANNING.md @@ -0,0 +1,120 @@ +# Dependency Vulnerability Scanning + +GuardScan 1.1 scans exact dependency versions against the OSV API. Use the dedicated command for a dependency-only audit, or include the same scanner in `security` and `scan`. + +## Quick start + +```bash +# Online lookup using the exact local inventory +guardscan config --offline=false +guardscan vuln . + +# Equivalent command aliases +guardscan cve . +guardscan audit . + +# Machine-readable CI report +guardscan vuln . --ci --format json --output vulnerabilities.json +``` + +`security` and `scan` include dependency vulnerabilities by default. Disable the scanner explicitly when a workflow only needs local source checks: + +```bash +guardscan security --no-cve +guardscan scan --no-cve +``` + +Use `--cve` to state the default explicitly. Use `--concurrency <1-16>` to bound scanner or advisory detail work. + +## Inventory and supported ecosystems + +GuardScan reads local manifests and lockfiles recursively, excluding generated and vendor directories. It currently recognizes: + +| Ecosystem | Inputs | OSV ecosystem | +| --- | --- | --- | +| JavaScript | `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, exact versions in `package.json` | npm | +| Python | pinned `requirements.txt` | PyPI | +| Go | `go.mod` | Go | +| Rust | `Cargo.lock` | crates.io | +| Ruby | `Gemfile.lock` | RubyGems | +| Java | exact dependencies in `pom.xml` | Maven | + +Only exact versions can be queried correctly. Unresolved ranges, malformed manifests, and unsupported lockfile records are coverage errors rather than evidence that a dependency is safe. `--scope runtime` excludes known development dependencies; `--scope all` is the default. + +The same local inventory powers offline SBOM generation. `guardscan sbom` therefore continues to list known exact dependencies without network access. `--format spdx` emits SPDX 2.3 and `--format cyclonedx` emits CycloneDX 1.7; both are validated against their official JSON schemas in tests and package smoke. License values may remain `NOASSERTION` or absent when neither a lockfile nor installed package contains license metadata; offline mode skips only network enrichment. + +## Online and offline coverage + +An online scan batches package coordinates through OSV and fetches advisory details. A complete successful response is stored atomically as a repository-specific snapshot unless `--no-cache` is set. + +```bash +# Create or replace coverage for the current exact inventory +guardscan config --offline=false +guardscan vuln db update . + +# Inspect freshness and inventory matching +guardscan vuln db status . + +# Use the snapshot without network access +guardscan vuln . --offline +``` + +Snapshots include the inventory digest, source endpoint, creation time, queried coordinates, and OSV records. The default freshness window is seven days. An offline scan fails closed when the snapshot is absent, stale, corrupt, or belongs to a different inventory. + +`--allow-partial` deliberately relaxes that completeness gate. Treat its results as incomplete evidence, not as a clean bill of health. `--refresh` forces online replacement and cannot be combined with offline mode. `--no-cache` prevents snapshot reads and writes, so it cannot supply an offline scan. + +Clear coverage independently of other caches: + +```bash +guardscan vuln db clear . --repo --force +guardscan vuln db clear --all --force +``` + +## Findings and deduplication + +GuardScan groups OSV records and aliases that describe the same advisory. Each result includes a canonical advisory ID, CVE aliases when available, ecosystem/package/version, dependency scope and paths, fixed versions, references, CWE IDs, and a stable SHA-256 fingerprint. + +When a fixed version is known, remediation recommends the lowest published fixed version later than the installed npm version, or the first published fixed version for other ecosystems. If OSV does not publish a fix, GuardScan reports that limitation instead of inventing an upgrade. + +## Severity and known-exploitation data + +Severity is derived from numeric OSV scores, CVSS 3.0/3.1 vectors, or qualitative ecosystem/database severity when available. Numeric scores and supported vectors map to low, medium, high, or critical policy severity. GuardScan preserves malformed or unsupported vectors without trusting them for policy. An advisory whose severity remains unknown is conservatively treated as medium for policy evaluation while retaining `advisorySeverity: "unknown"` in JSON. + +By default, online scans enrich CVE aliases with the official CISA Known Exploited Vulnerabilities catalog. The catalog is validated, size-bounded, fetched only over HTTPS, and cached for the configured vulnerability freshness window (seven days by default). Offline scans reuse the cached catalogβ€”even when staleβ€”and report the enrichment state as `fresh-cache`, `stale-cache`, or `unavailable`. Each ecosystem result includes `knownExploitedEnrichment` metadata, and a vulnerability is marked `knownExploited: true` when any of its CVE aliases occurs in the available catalog. + +When requested enrichment is unavailable, GuardScan emits `knownExploited: "unknown"`, preserves the enrichment source, freshness, status, and error metadata, and marks coverage partial. That condition exits with code `2` unless `--allow-partial` was explicitly supplied. `knownExploited: false` is reserved for a vulnerability that was checked against available KEV data and was not present. Enrichment can be disabled through `vulnerabilities.enrichKnownExploited: false` in `~/.guardscan/config.yml`. + +OSV coverage is advisory-database coverage, not proof of absence. Newly disclosed, private, withdrawn, incorrectly versioned, or ecosystem-specific vulnerabilities may be missing. + +## Formats and CI policy + +The dedicated command supports `table`, `json`, and `sarif`: + +```bash +guardscan vuln . --format table +guardscan vuln . --format json --output vulnerabilities.json +guardscan vuln . --format sarif --output vulnerabilities.sarif +``` + +CI mode defaults `--fail-on` to `high`. You can set a different threshold or a maximum count: + +```bash +guardscan vuln . --ci --fail-on critical +guardscan vuln . --max-vulnerabilities 0 +``` + +Exit codes are stable automation contracts: + +| Code | Meaning | +| --- | --- | +| `0` | Coverage completed and the finding policy passed | +| `1` | Coverage completed, but findings violated the policy | +| `2` | Operational or coverage failure, including missing offline coverage | + +The JSON schema identifier is `guardscan.vulnerability.v1`. SARIF uses SARIF 2.1.0 and points to the official OASIS errata schema. + +## Network and privacy + +OSV queries contain only package ecosystem, package name, and exact version. GuardScan does not include source code in advisory requests. The default endpoint is `https://api.osv.dev`; deployments can configure another compatible endpoint in `~/.guardscan/config.yml`. + +See [Privacy Policy](../PRIVACY.md) for offline boundaries, local cache contents, and telemetry behavior. diff --git a/docs/adrs/001-cloudflare-workers-backend.md b/docs/adrs/001-cloudflare-workers-backend.md index 3ac0c8c..db28225 100644 --- a/docs/adrs/001-cloudflare-workers-backend.md +++ b/docs/adrs/001-cloudflare-workers-backend.md @@ -1,17 +1,15 @@ # ADR 001: Cloudflare Workers for Backend Infrastructure ## Status -Accepted +Superseded for the CLI by [ADR 003](./003-privacy-first-architecture.md); retained as backend history. ## Date 2024-11-19 > **Note (2026-05):** The Worker implementation has moved out of this -> repository to [ntanwir10/GuardScan-Monitoring](https://github.com/ntanwir10/GuardScan-Monitoring) -> (private). This ADR still captures the original platform decision; current -> code, deployment scripts, and the Supabase schema live in that repo under -> `worker/`. The CLI continues to integrate via HTTP only, configurable through -> the `GUARDSCAN_API_URL` env var. +> repository. This ADR captures the original platform decision only. The current +> CLI has no monitoring integration and supports anonymous, explicit telemetry +> sync only through `GUARDSCAN_TELEMETRY_URL`. ## Context GuardScan needed a backend infrastructure for optional telemetry and monitoring. The backend requirements were: @@ -169,4 +167,3 @@ This decision should be reviewed if: - A clearly superior alternative emerges **Next review date**: 2025-05-19 (6 months) - diff --git a/docs/adrs/003-privacy-first-architecture.md b/docs/adrs/003-privacy-first-architecture.md index c0186d5..0dc264e 100644 --- a/docs/adrs/003-privacy-first-architecture.md +++ b/docs/adrs/003-privacy-first-architecture.md @@ -1,323 +1,32 @@ # ADR 003: Privacy-First Architecture ## Status -Accepted -## Date -2024-11-19 +Accepted; amended July 20, 2026. ## Context -GuardScan is a code analysis and security scanning tool that processes potentially sensitive source code. Users rightfully have concerns about: -1. **Code privacy** - Source code should never leave their machines -2. **Data ownership** - Users own their code and analysis results -3. **Vendor trust** - Minimal trust required in third-party services -4. **Compliance** - GDPR, CCPA, SOC 2, ISO 27001 requirements -5. **Transparency** - Clear understanding of what data (if any) is collected - -Traditional SaaS code analysis tools typically: -- Upload source code to cloud servers -- Analyze code on vendor infrastructure -- Store code and results in vendor databases -- Require significant trust from users - -This model is problematic for: -- Enterprises with strict data policies -- Developers working on confidential projects -- Organizations in regulated industries -- Privacy-conscious developers +GuardScan analyzes sensitive repositories. Its product contract must distinguish built-in local scanning, user-selected AI providers, public advisory services, local caches, and optional GuardScan telemetry. ## Decision -We adopted a **privacy-first, client-side architecture** where: - -1. **All code analysis happens locally** on the user's machine -2. **Source code never leaves the user's environment** -3. **Telemetry is optional** and **anonymized** -4. **AI features require user's own API keys** (BYOK - Bring Your Own Key) -5. **No user accounts or authentication required** -6. **Open source** for full transparency - -## Rationale - -### Core Privacy Principles - -1. **Zero Trust Model** - - We don't want access to user code - - We can't see what we don't receive - - No code = no liability, no compliance burden - - Users retain complete control - -2. **Local-First Processing** - - All scanning, analysis, and metrics computed locally - - No dependency on backend availability - - Works completely offline - - Fast (no network latency) - -3. **Optional, Anonymized Telemetry** - - **Opt-in only** (disabled by default with `--no-telemetry`) - - **No source code** sent - - **No file names** or paths - - **Only anonymized metadata**: LOC counts, action types, duration - - **Client ID**: Random UUID (not tied to identity) - - **Repo ID**: Cryptographic hash of git remote URL - -4. **BYOK for AI Features** - - Users provide their own API keys (OpenAI, Claude, Gemini, etc.) - - AI requests go directly from user to AI provider - - We never see API keys or AI requests/responses - - Users control costs and usage - -5. **Open Source Transparency** - - Full source code available on GitHub - - Users can audit exactly what is collected - - Can be forked and self-hosted - - Community can verify privacy claims - -### What We Collect (Optional Telemetry) - -**Metadata Only:** -```json -{ - "clientId": "uuid-generated-locally", // Random UUID, not tied to user - "repoId": "sha256-hash-of-git-remote", // One-way hash - "events": [{ - "action": "scan", // Action type (scan, review, etc.) - "loc": 10000, // Lines of code analyzed - "durationMs": 5000, // How long it took - "model": "gpt-4", // Which AI model used (if any) - "timestamp": 1700000000000, // When it happened - "metadata": { // Generic metadata - "language": "typescript" // Programming language - } - }] -} -``` -**Never Collected:** -- ❌ Source code -- ❌ File names or paths -- ❌ Variable/function names -- ❌ Code structure or AST -- ❌ Security findings (specific vulnerabilities) -- ❌ User identity (name, email, IP) -- ❌ API keys -- ❌ Git commit messages or diffs -- ❌ Environment variables - -### Why Collect Telemetry at All? - -**Product Improvement:** -- Understand which features are used -- Identify performance bottlenecks -- Prioritize development efforts -- Track adoption and growth - -**Error Monitoring:** -- Crash reports (stack traces only, no code) -- API errors (generic errors, no request content) -- Performance issues - -**Business Metrics:** -- Active users (counted anonymously) -- Feature adoption rates -- Geographic distribution (for CDN optimization) - -**Important:** All telemetry is **optional** and can be: -- Disabled with `--no-telemetry` flag -- Disabled in config: `"telemetryEnabled": false` -- Worked around by firewall/network blocks (graceful degradation) +- Built-in static analysis runs locally and does not upload source to GuardScan. +- Cloud AI is BYOK and sends selected context directly to the configured provider; loopback Ollama and LM Studio remain local options. +- Offline mode blocks GuardScan cloud AI, cloud embeddings, advisory lookups, update checks, and telemetry recording and delivery. +- Telemetry is disabled by default, requires explicit consent, queues locally, and is delivered only by `guardscan telemetry sync` to a user-configured HTTPS collector. +- Telemetry contains only event ID, action category, aggregate LOC, duration, coarse execution mode, and timestamp. +- Telemetry excludes installation and repository identifiers, source, paths, prompts, responses, findings, model names, languages, errors, dependency data, and arbitrary metadata. +- Disabling telemetry deletes queued events. Local telemetry is retained for at most 30 days and 1,000 events while consent remains enabled. +- API credentials and source-derived caches are stored locally with restrictive permissions and explicit clearing commands. ## Consequences -### Positive -- **User trust**: Users know their code is safe -- **Compliance**: No data = no GDPR/CCPA/SOC2 compliance burden -- **Performance**: Local analysis is faster than cloud -- **Offline**: Works without internet connection -- **Cost**: No expensive cloud processing -- **Enterprise-friendly**: Meets strictest security policies - -### Negative -- **Limited insights**: Can't see actual code to help debug -- **Harder to support**: Can't reproduce issues without access -- **Feature limitations**: Some features harder without backend - - *Mitigation*: BYOK model for AI features - - *Mitigation*: Local vector embeddings for RAG -- **Adoption metrics**: Less detailed than typical SaaS - - *Mitigation*: Optional telemetry provides sufficient insights - -### Trade-offs - -**What We Give Up:** -1. **Detailed error reports**: Can't see user code causing errors - - *Mitigation*: Stack traces and logs still useful - - *Mitigation*: Users can share code voluntarily for debugging - -2. **Usage analytics**: Less granular than typical SaaS - - *Mitigation*: Anonymized telemetry sufficient for product decisions - - *Mitigation*: User surveys and feedback - -3. **Centralized features**: Can't offer cloud-based features easily - - *Mitigation*: BYOK model for AI - - *Mitigation*: Local-first alternatives (embeddings, caching) - -**What We Gain:** -1. **User trust**: Developers trust tools that respect privacy -2. **Enterprise adoption**: Can be used in highly regulated industries -3. **Competitive advantage**: Differentiation from SaaS competitors -4. **Simplicity**: No user accounts, authentication, or authorization -5. **Lower costs**: No expensive AI API bills on our end - -## Implementation Details - -### Client-Side Architecture -``` -User's Machine: -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ GuardScan CLI β”‚ -β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Code Scanners β”‚ (all local) β”‚ -β”‚ β”‚ - Secrets β”‚ β”‚ -β”‚ β”‚ - OWASP β”‚ β”‚ -β”‚ β”‚ - Dependencies β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ AI Features β”‚ (BYOK) β”‚ -β”‚ β”‚ - Code Review β”‚ ────────────┐ β”‚ -β”‚ β”‚ - Explain β”‚ β”‚ β”‚ -β”‚ β”‚ - Test Gen β”‚ β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ -β”‚ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ -β”‚ β”‚ Telemetry β”‚ (optional) β”‚ β”‚ -β”‚ β”‚ - Anonymizer β”‚ ─────┐ β”‚ β”‚ -β”‚ β”‚ - Batch Sender β”‚ β”‚ β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”˜ - β”‚ β”‚ - β”‚ β”‚ - Optional Direct - Metadata API - β”‚ β”‚ - β–Ό β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Backend β”‚ β”‚ AI β”‚ - β”‚(Our API) β”‚ β”‚Providerβ”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - Stores only User's API - anonymized key required - metadata -``` - -### Privacy Features in Code - -**1. Client ID Generation** -```typescript -// Generated once, stored locally -const clientId = crypto.randomUUID(); -// Never sent to any identity service -// Never tied to user information -``` - -**2. Repo ID Hashing** -```typescript -// One-way hash of git remote URL -const repoId = crypto.createHash('sha256') - .update(gitRemoteUrl) - .digest('hex'); -// Impossible to reverse to original URL -``` - -**3. Telemetry Anonymization** -```typescript -function anonymizeTelemetry(event: TelemetryEvent): SafeTelemetry { - return { - action: event.action, // Generic action name - loc: event.loc, // Numeric count only - durationMs: event.durationMs,// Timing only - model: event.model, // AI model name (if used) - // ❌ No file paths - // ❌ No code content - // ❌ No user identity - // ❌ No specific findings - }; -} -``` - -**4. BYOK Implementation** -```typescript -// User's API key stored locally only -const apiKey = config.get('providers.openai.apiKey'); - -// Direct request to AI provider (not through our backend) -const response = await fetch('https://api.openai.com/v1/chat/completions', { - headers: { - 'Authorization': `Bearer ${apiKey}`, // User's key - }, - body: JSON.stringify({ - messages: [{ role: 'user', content: prompt }] // User controls prompt - }) -}); - -// We never see the request or response -``` - -**5. Offline Mode** -```typescript -// All features work offline except AI (requires user's API key) -if (config.offlineMode) { - // Disable telemetry - // Disable update checks - // All scanning still works -} -``` - -### Privacy Documentation - -1. **README.md**: Clear privacy section -2. **Privacy Policy**: Simple, understandable -3. **Telemetry docs**: Exact data collected -4. **Source code**: Audit-able on GitHub - -## Related Decisions -- [ADR 001: Cloudflare Workers Backend](./001-cloudflare-workers-backend.md) - Backend for optional telemetry -- [ADR 005: BYOK AI Model](./005-byok-ai-model.md) - User brings their own AI keys - -## Compliance - -### GDPR (EU General Data Protection Regulation) -- βœ… No personal data collected (anonymous UUIDs) -- βœ… Opt-in telemetry (consent) -- βœ… Right to be forgotten (delete config file) -- βœ… Data portability (local storage) -- βœ… Transparency (open source) - -### CCPA (California Consumer Privacy Act) -- βœ… No personal information sale -- βœ… Opt-out available (`--no-telemetry`) -- βœ… Data access (local files) -- βœ… Data deletion (delete config) - -### SOC 2 (Service Organization Control 2) -- βœ… Security: No code transmission -- βœ… Availability: Local-first architecture -- βœ… Confidentiality: No code access -- βœ… Processing integrity: Local validation -- βœ… Privacy: No personal data - -## References -- [GDPR Guidelines](https://gdpr.eu/) -- [CCPA Overview](https://oag.ca.gov/privacy/ccpa) -- [Privacy by Design](https://www.privacy-first.com/) -- [Open Source Privacy Benefits](https://opensource.com/article/21/12/open-source-privacy) - -## Review -This decision is fundamental and should rarely change. Review if: -- Major feature requires cloud processing -- Privacy regulations significantly change -- Competitive landscape shifts dramatically +- GuardScan cannot correlate anonymous telemetry across installations or repositories. +- Product analytics are intentionally limited to explicitly synchronized aggregate events. +- Cloud-provider and advisory-service privacy terms remain separate from GuardScan telemetry. +- The CLI remains useful without a GuardScan backend, account, or network connection. +- Privacy regressions require release-blocking tests for persistent and command-level offline controls. -**Next review date**: 2025-11-19 (1 year) +## Active contract +The wire schema is `guardscan.telemetry.v1`; see [API Documentation](../API.md). The earlier client-ID, repository-hash, free-form metadata, monitoring endpoint, and automatic batching designs are retired and are not compatibility contracts. diff --git a/docs/adrs/005-byok-ai-model.md b/docs/adrs/005-byok-ai-model.md index cd0a170..ee4806f 100644 --- a/docs/adrs/005-byok-ai-model.md +++ b/docs/adrs/005-byok-ai-model.md @@ -18,7 +18,7 @@ GuardScan includes AI-enhanced features like code review, explanation, test gene Traditional SaaS approaches: - **Vendor-paid**: Company pays for all AI calls (expensive, unsustainable) - **Subscription**: Users pay monthly fee (bundled cost, inflexible) -- **Credit system**: Pre-purchase credits (complexity, vendor lock-in) +- **Hosted prepaid usage model**: rejected due to complexity and vendor lock-in ## Decision We adopted a **BYOK (Bring Your Own Key)** model where: @@ -95,8 +95,8 @@ Result: Sustainable at any scale βœ… # Create key: sk-proj-... # 2. Configure GuardScan -guardscan config set providers.openai.apiKey sk-proj-... -guardscan config set providers.openai.model gpt-4 +edit ~/.guardscan/config.yml: providers.openai.apiKey sk-proj-... +edit ~/.guardscan/config.yml: providers.openai.model gpt-4 # 3. Use AI features guardscan review src/app.ts @@ -333,7 +333,7 @@ Options: 2. Anthropic (Claude): https://console.anthropic.com/ 3. Ollama (Free, Local): https://ollama.ai/ -Or run: guardscan config set providers.openai.apiKey YOUR_KEY +Or run: edit ~/.guardscan/config.yml: providers.openai.apiKey YOUR_KEY ``` ## Related Decisions @@ -353,4 +353,3 @@ This decision is strategic and should rarely change. Review if: - User feedback strongly negative **Next review date**: 2025-05-19 (6 months) - diff --git a/docs/adrs/006-node-sea-standalone-distribution.md b/docs/adrs/006-node-sea-standalone-distribution.md new file mode 100644 index 0000000..4ee0e78 --- /dev/null +++ b/docs/adrs/006-node-sea-standalone-distribution.md @@ -0,0 +1,110 @@ +# ADR 006: Node.js SEA for standalone GuardScan distribution + +## Status + +Proposed + +## Date + +2026-07-25 + +## Context + +GuardScan's npm package requires Node.js. Homebrew, Scoop, WinGet, Chocolatey, and an optional `pipx` channel need an immutable executable that runs when Node and npm are absent from `PATH`. Those channels must install the same GuardScan implementation and may not bootstrap through npm, `npx`, or an unversioned download. + +The CLI currently has characteristics that constrain the builder: + +- TypeScript compiles to CommonJS and the entry point uses dynamic imports for command modules. +- Package metadata is imported at runtime in several modules. +- TypeScript is a runtime dependency for AST-backed features. +- `tiktoken` and chart rendering are optional. Chart rendering includes native bindings and must degrade cleanly when unavailable. +- Node.js SEA executes one embedded script. Its injected `require()` loads built-ins only, so all required JavaScript dependencies must be bundled into that script. +- SEA code cache and V8 snapshots are platform-specific. They cannot be used for a cross-platform build. +- macOS and Windows executable mutation affects platform signatures, so final signing must happen after SEA blob injection. + +## Decision + +Use a two-stage host-native build: + +1. Bundle the compiled CLI and required JavaScript dependencies into one CommonJS file with a pinned `esbuild`. +2. Generate a Node.js SEA blob with `useCodeCache: false` and `useSnapshot: false`, copy the exact CI Node executable, inject the blob with a pinned `postject`, smoke-test it with Node absent from `PATH`, and only then archive and sign it. + +Each target is built on its native hosted runner: + +- macOS arm64 and x64 on macOS runners; +- Linux x64 and arm64 glibc on Linux runners; +- Windows x64 on a Windows runner. + +The first prototype is host-platform only. It emits explicitly non-publishable prototype metadata and cannot be consumed by adapter rendering. Production artifact metadata is generated only after archive reproducibility, platform signing, provenance, and the full standalone smoke contract pass. + +`tiktoken` and `chartjs-node-canvas` remain external optional capabilities. The standalone executable reports token estimates and omits chart images when those modules are unavailable. Core static scanning, dependency inventory, vulnerability snapshot use, and SPDX/CycloneDX SBOM generation remain required capabilities. + +Python wheels, if approved, bundle the exact already-tested platform executable. The Python package contains only a small launcher and metadata; it does not contain a second GuardScan implementation and does not download a runtime during installation or first use. + +## Rationale + +Node SEA keeps the runtime aligned with the implementation and avoids an unsupported JavaScript-runtime fork. A one-file CommonJS bundle satisfies SEA's module-loading constraint and lets the existing CLI remain the source of behavior. + +Host-native builds make signing and smoke testing explicit and avoid platform-specific code-cache or snapshot hazards. Disabling both features trades some startup optimization for portability and lower release risk. + +The alternatives were rejected for the initial implementation: + +- `pkg` and similar archived bundlers introduce a second runtime patch set and uncertain support for current Node releases. +- `bun build --compile` would make Bun runtime compatibility a product contract that the current Node-oriented test suite does not establish. +- Shipping a shell, Python, or PowerShell bootstrapper that downloads Node/npm violates offline, immutability, and install-time execution requirements. +- Reimplementing the CLI in Python for PyPI would create divergent behavior and release identities. + +## Consequences + +### Positive + +- Native package managers can eventually install one immutable executable without Node. +- npm and standalone channels retain one implementation and version. +- Every target is built and tested where its signing and runtime behavior can be observed. +- Optional native modules cannot block core standalone startup. +- PyPI can remain a thin transport for the same executable. + +### Negative + +- The executable includes a Node runtime and will be materially larger than the npm package. +- Bundling the TypeScript compiler increases artifact size. +- Optional accurate tokenization and chart rendering are unavailable in the initial standalone capability profile. +- Five host-native builds, notarization, Authenticode, and provenance add release cost. +- `postject` is an additional release-critical dependency and must remain exactly pinned, lockfile-verified, and covered by artifact smoke tests. + +## Implementation details + +- Builder dependencies are development-only, exact-version pinned, and never installed by end users. +- The bundle target is the minimum supported runtime (`node22`), CommonJS, one output file, no code splitting. +- The builder fails on unresolved required imports and externalizes only an explicit allowlist of optional native packages. +- Prototype output includes source version, commit, platform, architecture, Node runtime, bundle and executable SHA-256, size, capabilities, and `productionReady: false`. +- Smoke tests run `--version`, `--help`, an offline static-only scan, SBOM generation, and telemetry status in an isolated home. `PATH` excludes Node and package managers. +- Release archives are generated in a later work item with normalized paths, modes, ownership, timestamps, and ordering. +- Production manifests require exact versioned GitHub Release URLs, checksums, signature evidence, and provenance. Adapters cannot render from prototype metadata. +- macOS signing/notarization and Windows signing happen after injection. Release publication fails if signature verification is unavailable or incomplete. + +## Acceptance before status changes to Accepted + +- The exact prototype passes on every supported OS/architecture target. +- Required CLI commands pass with Node absent from `PATH`. +- Bundle analysis confirms there are no undeclared runtime filesystem dependencies. +- Optional modules degrade according to the documented capability profile. +- Production archive reproducibility and archive extraction safety are proven. +- macOS notarization, Authenticode, checksums, SBOMs, and provenance are verified against exact release artifacts. + +## Related decisions + +- [ADR 003: Privacy-First Architecture](./003-privacy-first-architecture.md) +- [ADR 005: BYOK AI Model](./005-byok-ai-model.md) + +## References + +- Node.js 22 single executable applications documentation +- esbuild JavaScript build API +- GuardScan multi-channel distribution and launch plan + +## Review + +Review after the host-native CI feasibility matrix completes. + +**Next review date**: 2026-08-15 diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 5c82ef7..e545529 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -33,6 +33,10 @@ Architecture Decision Records (ADRs) are a lightweight way to document important - **Status**: Accepted - **Summary**: Users provide their own AI API keys for AI features, enabling privacy, cost control, and sustainability +- [ADR 006: Node.js SEA for standalone distribution](./006-node-sea-standalone-distribution.md) + - **Status**: Proposed + - **Summary**: Bundle GuardScan into host-native Node.js single executables for native package managers and optional pipx wheels + ### Development & Tooling - [ADR 004: TypeScript Strict Mode](./004-typescript-strict-mode.md) @@ -152,4 +156,3 @@ If you have questions about ADRs or need help writing one: - Open a GitHub Discussion - Ask in the team chat - Refer to existing ADRs as examples - diff --git a/tasks/plan.md b/tasks/plan.md new file mode 100644 index 0000000..603cd58 --- /dev/null +++ b/tasks/plan.md @@ -0,0 +1,368 @@ +# GuardScan multi-channel distribution and launch plan + +Status: historical design record, superseded by the approved RC-first zero-touch release train. This document does not authorize a release by itself. The current operating contract is in `docs/RELEASE_AUTOMATION.md`, and provider onboarding is in `docs/RELEASE_ONBOARDING.md`. + +## Implementation status β€” 2026-07-25 + +The complete local implementation now includes: + +- an append-only, hash-chained release ledger and machine-generated 24-hour promotion decision; +- exact-source RC derivation and stable promotion through a protected GitHub App identity; +- production artifact manifests covering npm, five signed SEA archives, and five executable-bearing PyPI wheels; +- OIDC publication for npm and PyPI, immutable GitHub assets, and generated Homebrew, Scoop, WinGet, and Chocolatey adapters; +- scheduled public canaries, deterministic reconciliation, and append-only rollback/forward-fix planning; +- required local and hosted gates for Node 22/24/26, npm/pnpm/Yarn/Bun, offline scan, SPDX, CycloneDX, signatures, provenance, and package lifecycle checks. + +Local tests and contract gates pass. Hosted signing, registry publication, the 24-hour RC soak, and moderated-channel acceptance remain pending the one-time external onboarding in `docs/RELEASE_ONBOARDING.md`. + +## Outcome + +Ship GuardScan through trustworthy, testable installation channels without creating divergent implementations: + +- npm remains the canonical Node package. +- npm-compatible clients (pnpm, Yarn, and Bun) consume that same npm package; they are compatibility targets, not separate publications. +- GitHub Releases becomes the canonical source for versioned standalone executables and their integrity metadata. +- Homebrew, Scoop, WinGet, and Chocolatey become thin installation adapters over immutable release artifacts. +- PyPI publishes platform wheels that bundle the exact signed release binary and expose a console entry point; it does not invoke npm, npx, or an unverified runtime download. +- Every channel carries the same GuardScan version and has an explicit install, upgrade, uninstall, smoke-test, rollback, and ownership contract. +- Routine future releases are prepared, built, verified, published, and monitored through reusable automation; a machine policy promotes an unchanged RC after its 24-hour soak without hand-edited versions, checksums, URLs, manifests, or human approval. + +## Historical baseline at plan approval + +- The repository package is `guardscan@1.1.0`; npm `latest` is currently `1.0.5`. +- The npm package has a valid `bin` entry and Node shebang, and the package smoke suite verifies packed contents and important offline/privacy contracts. +- CI tests Node 18/20/22/24/26 on Ubuntu and Windows, but package smoke runs only with Node 24 and does not test macOS or invoke a globally installed `guardscan` shim. +- The declared runtime floor is Node 18 even though Node 18 and Node 20 are end-of-life. Optional native chart dependencies also have a narrower Node 18 floor than the package declaration. +- Tag releases publish npm first and then create a notes-only GitHub Release. There are no standalone binaries, checksums, release manifest, signatures, attestations, Homebrew formulae, Python distributions, Chocolatey packages, Scoop manifests, or WinGet manifests. +- Release logic currently lives in one growing CI workflow, has no reusable release-tool contract, and does not persist enough machine-readable state to safely resume a partially published multi-channel release. +- The current worktree is a large, dirty remediation branch. It must be split and reviewed before it becomes a release source. + +## Architectural decisions + +1. **One implementation and one version.** The TypeScript/Node CLI remains the product implementation. Adapters may launch it but may not fork behavior. +2. **Separate runtime-bearing and runtime-free channels.** npm, pnpm, Yarn, and Bun installs require a supported Node runtime unless GuardScan explicitly passes a future Bun-runtime compatibility gate. Native channels use standalone executables and must run with Node absent from `PATH`. +3. **Build once per immutable tag.** A release workflow creates all publishable artifacts from one protected tag/commit, tests the exact artifacts, then promotes them. Downstream package metadata references those exact assets and SHA-256 values. +4. **No install-time execution of unversioned code.** Installers may download only versioned immutable assets over HTTPS and must verify checksums. PyPI will not bootstrap through npm/npx, and package adapters will not use a `latest` download URL. +5. **No big-bang multi-registry publish.** Ship in checkpoints: npm-compatible clients, standalone artifacts, first-party native channels, community registries, then optional PyPI. +6. **Truthful support labels.** Documentation identifies channels as stable, preview, community-reviewed, or deferred. Availability in a package manager is not described as runtime compatibility with that package manager's JavaScript engine. +7. **Rollback is designed before publishing.** Every channel gets an emergency procedure and retained-version policy before its first stable release. +8. **One declarative release authority.** `cli/package.json` remains the product-version source, while a generated release manifest becomes the authority for artifact names, digests, URLs, capabilities, and channel rendering. Derived data is never copied manually between workflows or package-manager files. +9. **Thin workflows, testable release code.** GitHub Actions YAML coordinates jobs and permissions; deterministic version resolution, manifest generation, validation, rendering, and release-state logic lives in small, unit-tested scripts that run identically locally and in CI. +10. **Generated adapters with checked-in review diffs.** Homebrew, Scoop, WinGet, Chocolatey, optional PyPI, and documentation snippets are rendered from templates plus the release manifest. Automation opens reviewable update pull requests instead of directly mutating every downstream stable channel. +11. **Idempotent, resumable publication.** Each publish step records immutable input/output identity and treats an already-published matching artifact as success; a mismatched artifact is a hard failure. A retry resumes from verified state and never rebuilds or overwrites the version. +12. **Cost-aware verification tiers.** Pull requests run fast package and generator checks, release candidates run the full platform/artifact matrix, stable promotion reuses those exact tested artifacts, and scheduled canaries use a minimal representative matrix. + +## Target channel matrix + +| Channel | Publication model | Initial user command | Initial status | +| --- | --- | --- | --- | +| npm | Publish `guardscan` to npm with trusted publishing | `npm install -g guardscan` | Canonical Node channel | +| pnpm | Reuse npm package; no second publication | `pnpm add -g guardscan` or `pnpm dlx guardscan@VERSION` | Phase 1 | +| Yarn Modern | Reuse npm package; prefer one-shot/project-local use | `yarn dlx guardscan@VERSION` | Phase 1 | +| Yarn Classic | Reuse npm package; legacy compatibility only | `yarn global add guardscan` | Best effort, tested separately | +| Bun | Reuse npm package; Node remains required until proven otherwise | `bun install --global guardscan` or `bunx guardscan@VERSION` | Phase 1 preview until matrix passes | +| GitHub Releases | Signed/attested archives per OS/CPU plus manifest/checksums | Download a versioned archive | Phase 2 canonical binary channel | +| Homebrew | First-party tap referencing immutable release assets | `brew install ntanwir10/tap/guardscan` | Phase 3; core later | +| Scoop | JSON manifest referencing Windows portable archive | `scoop install guardscan` via first-party bucket | Phase 3 | +| WinGet | Community manifest referencing signed Windows artifact | `winget install .GuardScan` | Phase 3 after binary stability | +| Chocolatey | `.nupkg` referencing or embedding the official Windows artifact | `choco install guardscan` | Phase 3 after binary stability | +| PyPI/pipx | Platform wheels bundling the exact standalone binary | `pipx install guardscan-cli` (name to be reserved) | Phase 4 decision gate | +| GHCR | Versioned OCI image for CI and isolated scans | `docker run ... ghcr.io/ntanwir10/guardscan:VERSION` | Optional Phase 4 | + +## Workstream 0: release baseline and product contract + +### GS-DIST-001 β€” Split and stabilize the current release candidate + +- **Depends on:** none. +- **Files/systems:** current worktree, changelog, branch protection, CI. +- **Scope:** split the broad remediation into independently reviewable commits or stacked pull requests; preserve user changes; reconcile the `1.1.0` changelog and release notes; run all existing release gates from a clean checkout. +- **Acceptance:** no unexplained dirty files; every release-critical change is reviewed; `git diff --check`, typecheck, build, full tests, coverage, lint ratchet, audit, package smoke, and packed-artifact inspection pass on the exact release commit. +- **Verification:** clone or checkout the protected commit into a clean worktree and execute the documented release-gate sequence. + +### GS-DIST-002 β€” Define supported runtimes, operating systems, and capabilities + +- **Depends on:** GS-DIST-001. +- **Files:** `cli/package.json`, CI matrix, README/support policy, optional-chart code and tests. +- **Scope:** raise the supported Node floor to a non-EOL line, recommended Node `>=22`; define stable OS/CPU targets; state whether chart rendering is optional; distinguish npm-installed and standalone capabilities. +- **Acceptance:** package metadata, docs, CI, native dependency behavior, and error messages agree; unsupported runtimes fail clearly; no EOL Node line is advertised as supported. +- **Verification:** test the exact minimum Node release plus current supported LTS lines; test core CLI both with and without optional native chart dependencies. + +### GS-DIST-003 β€” Reserve names and secure publisher identities + +- **Depends on:** none. +- **Systems:** npm, GitHub, Homebrew tap, Chocolatey, PyPI/TestPyPI, WinGet, Scoop. +- **Scope:** search for existing packages and ownership conflicts; reserve available names; define publisher IDs; enable hardware-backed 2FA; document owners and recovery contacts; use OIDC/trusted publishing where supported. +- **Acceptance:** each proposed identifier has an owner and collision decision; no production credential is stored in the repository; at least two trusted maintainers can recover release access. +- **Verification:** read-only ownership audit and a non-production/TestPyPI or draft-package authentication rehearsal. + +### GS-DIST-004 β€” Automate version, changelog, and release-candidate preparation + +- **Depends on:** GS-DIST-001. +- **Files:** release automation configuration, `cli/package.json`, `cli/package-lock.json`, `cli/CHANGELOG.md`, contribution/release docs. +- **Scope:** select and configure a maintained release-PR mechanism suitable for this single-package repository; derive the next semantic version from reviewed change metadata; update package and lock versions plus changelog in one reviewable pull request; create a protected tag only from the merged release commit. +- **Acceptance:** maintainers do not manually synchronize version strings or changelog headings; a no-change run is a no-op; prerelease and stable versions are deterministic; breaking/minor/patch intent is visible before merge. +- **Verification:** fixture-based dry runs for patch, minor, major, prerelease, no-change, and malformed-history cases, followed by a non-publishing release-PR rehearsal. + +## Workstream 1: npm-compatible client launch + +### GS-DIST-101 β€” Harden the npm artifact and global binary contract + +- **Depends on:** GS-DIST-001, GS-DIST-002. +- **Files:** `cli/package.json`, `cli/package-lock.json`, `cli/scripts/package-smoke.js`, `.github/workflows/ci.yml` or a dedicated package workflow. +- **Scope:** test an actual global install and the generated `guardscan` shim, not only `node dist/index.js`; validate `--version`, `--help`, initialization, offline static scan, SBOM, telemetry-disabled state, upgrade, and uninstall; add macOS. +- **Acceptance:** the packed tarball passes on Linux, macOS, and Windows using the supported Node floor and primary LTS; optional native dependencies either install successfully or produce documented graceful degradation. +- **Verification:** PR jobs install the locally packed tarball globally in isolated homes with install scripts both enabled and disabled. + +### GS-DIST-102 β€” Add pnpm, Yarn, and Bun compatibility smoke jobs + +- **Depends on:** GS-DIST-101. +- **Files:** new package-manager smoke script(s), CI workflow, install documentation. +- **Scope:** test pnpm global and `dlx`; Yarn Modern `dlx` and project-local execution; Yarn Classic global only as legacy coverage; Bun global and `bunx`; pin tested package-manager versions in CI. +- **Acceptance:** each advertised command launches the packed GuardScan artifact, reports the expected version, completes a non-destructive offline scan, and leaves telemetry disabled. Bun documentation explicitly says Node is required unless a Node-free Bun runtime test passes. +- **Verification:** run the client matrix against the local tarball on PRs and against `guardscan@VERSION` after publication. Do not create extra application lockfiles solely to publish through these clients. + +### GS-DIST-103 β€” Add post-publication registry canaries + +- **Depends on:** GS-DIST-102. +- **Files/systems:** release workflow, scheduled workflow. +- **Scope:** after npm publish, install the exact version through npm, pnpm, Yarn Modern, and Bun; add a daily read-only canary for the latest stable version. +- **Acceptance:** a release is not marked complete until the registry version is resolvable and the client matrix passes; failures produce a channel-status issue or alert without collecting end-user telemetry. +- **Verification:** rehearse with an npm prerelease/dist-tag before the stable release. + +## Workstream 2: standalone artifact foundation + +### GS-DIST-201 β€” Decide the standalone build technology through an ADR and spike + +- **Depends on:** GS-DIST-002. +- **Files:** `docs/adrs/`, isolated build prototypes, no production packaging switch until approved. +- **Scope:** compare Node Single Executable Applications, a maintained Node bundler/packager, and Bun compilation against GuardScan's dynamic imports, `package.json` access, schemas, optional native modules, subprocesses, and private-state behavior. +- **Acceptance:** the ADR records security maintenance, licensing, platform coverage, binary size/startup, native-module support, asset embedding, reproducibility, code signing, and failure behavior. The chosen prototype passes the core offline smoke suite with Node removed from `PATH`. +- **Verification:** build and execute prototypes on native Linux, macOS, and Windows runners; reject any option that silently omits required scanners or schemas. + +### GS-DIST-202 β€” Make the CLI bundle/standalone safe + +- **Depends on:** GS-DIST-201. +- **Files:** entrypoint/module loading, schema asset resolution, version loading, report/chart boundaries, build configuration and focused tests. +- **Scope:** eliminate unsupported dynamic loading in the selected builder; embed or colocate versioned schemas; isolate optional chart rendering; preserve safe project-code and offline policies. +- **Acceptance:** help/version/startup do not load optional native modules; static scan and both SBOM formats work; unsupported optional features return explicit status rather than crashing; no source token, environment file, test fixture, or local state is embedded. +- **Verification:** inspect artifact contents and run adversarial package smoke in temporary homes and untrusted fixture repositories. + +### GS-DIST-203 β€” Build the initial native target matrix + +- **Depends on:** GS-DIST-202. +- **Files:** dedicated release workflow and packaging scripts. +- **Scope:** initially target `darwin-arm64`, `darwin-x64`, `linux-x64-gnu`, `linux-arm64-gnu`, and `windows-x64`; defer Alpine/musl and Windows ARM64 until tested. +- **Acceptance:** each archive uses a stable name such as `guardscan-vVERSION-OS-ARCH`; the executable reports the tag version and operates with no Node installation; archives include license/notices. +- **Verification:** build on native runners where practical and run install, help, init, offline scan, SBOM, upgrade-replacement, and uninstall/removal tests. + +### GS-DIST-204 β€” Add release integrity, provenance, and machine-readable metadata + +- **Depends on:** GS-DIST-203. +- **Files:** release workflow, `release-manifest` schema/script, security and install docs. +- **Scope:** generate SHA-256 sums, per-artifact SBOMs, GitHub artifact attestations, and a release manifest containing version, commit, target, size, digest, URL, capability flags, and signature/attestation references; version the manifest schema, use stable key/list ordering, define compatibility and migration policy, retain a release-evidence bundle, and add macOS signing/notarization plus Windows code-signing workstreams. +- **Acceptance:** every executable asset is represented in the manifest and checksums; users have documented verification commands; signing failures block stable promotion; release assets are immutable after promotion; the evidence bundle records resolved metadata, tool versions, validation results, and artifact inventory for future audits and resumes. +- **Verification:** verify checksums and attestations from a clean machine; verify platform signatures using native tools; verify the manifest against its schema; run golden compatibility tests against current and prior supported manifest versions. + +### GS-DIST-205 β€” Create native artifact smoke and compatibility gates + +- **Depends on:** GS-DIST-204. +- **Files:** native smoke harness, CI workflows, fixtures. +- **Scope:** test no-Node execution, offline/no-egress behavior, privacy defaults, file permissions, path handling, Unicode/spaces, exit codes, project-code opt-in, malformed state, and optional feature degradation. +- **Acceptance:** every target passes before release promotion; failures identify target and capability; no target is published with a reduced capability set unless the release manifest and docs say so. +- **Verification:** exact downloaded release assets, not rebuilt substitutes, pass the same tests after draft upload. + +### GS-DIST-206 β€” Build deterministic package-adapter renderers + +- **Depends on:** GS-DIST-204, GS-DIST-500. +- **Files:** versioned adapter templates, renderer modules, golden fixtures, native-validator wrappers. +- **Scope:** define the narrow input/output contract that converts one release manifest into Homebrew, Scoop, WinGet, Chocolatey, optional PyPI, and installation-document metadata; keep rendering pure and side-effect free; emit stable, reviewable output with generated-file provenance headers where the format permits. +- **Acceptance:** identical manifest/template inputs produce byte-identical output; every emitted version, URL, digest, architecture, and capability comes from canonical metadata; generated output passes its ecosystem validator before any channel publication. +- **Verification:** golden tests, repeated-build identity checks, malformed/unknown-schema fixtures, and each native package-manager validator against non-publishing fixtures. + +## Workstream 3: native package-manager adapters + +### GS-DIST-301 β€” Launch a first-party Homebrew tap + +- **Depends on:** GS-DIST-204, GS-DIST-205, GS-DIST-206, GS-DIST-003. +- **Files/systems:** preferably a dedicated `ntanwir10/homebrew-tap` repository, formula template/update automation, release docs. +- **Scope:** create a formula or tap-specific binary adapter using immutable versioned assets and checksums; support Apple Silicon, Intel macOS, and Linuxbrew where artifacts exist; avoid self-update behavior. +- **Acceptance:** `brew audit`, `brew style`, install, test, upgrade, and uninstall pass; formula test executes `guardscan --version` and a safe offline command; formula version/digest match the release manifest. +- **Verification:** test from a clean macOS runner on both available architectures and a Linuxbrew runner. Start with the first-party tap; submit to `homebrew/core` only after stability, usage/notability, and source-build requirements are met. + +### GS-DIST-302 β€” Launch Scoop and prepare WinGet + +- **Depends on:** GS-DIST-204, GS-DIST-205, GS-DIST-206, GS-DIST-003. +- **Files/systems:** first-party Scoop bucket, WinGet manifests or submission automation. +- **Scope:** create architecture-aware manifests with immutable URLs and SHA-256; map the executable to `guardscan`; define update automation and retained-version behavior. +- **Acceptance:** Scoop install/update/uninstall and `checkver` pass; WinGet manifests validate and pass Windows Sandbox install/upgrade/uninstall before submission. +- **Verification:** test Windows without Node installed, and verify the executable hash against the release manifest before and after each adapter install. + +### GS-DIST-303 β€” Launch Chocolatey + +- **Depends on:** GS-DIST-204, GS-DIST-205, GS-DIST-206, GS-DIST-003. +- **Files/systems:** Chocolatey packaging source, `.nuspec`, install/uninstall scripts, community repository account. +- **Scope:** package the official portable Windows artifact or download it from its immutable release URL; enforce checksum verification; include license, project URLs, release notes, and silent install behavior; automate version/checksum updates only after the first package is approved. +- **Acceptance:** `choco pack`, package validation, local install, upgrade, uninstall, verification, and cleanup pass in Windows Sandbox; no unversioned URL or mutable script executes; community moderation requirements are satisfied. +- **Verification:** local feed test followed by a prerelease/community moderation rehearsal; stable documentation is enabled only when the public package is approved. + +## Workstream 4: PyPI decision and optional launcher + +### GS-DIST-401 β€” Validate Python-channel product value and naming + +- **Depends on:** GS-DIST-205, GS-DIST-003. +- **Files:** ADR/product decision, no PyPI publication yet. +- **Scope:** measure whether Python users need a PyPI-native installation path; check `guardscan` and `guardscan-cli` ownership; compare platform-wheel maintenance with the simpler native channels. +- **Acceptance:** an explicit go/no-go decision identifies supported Python versions, operating systems, wheel tags, package name, support burden, and deprecation policy. +- **Verification:** user/support evidence and a TestPyPI prototype. Default decision is no-go if the package only wraps npm/npx or downloads unverified code at runtime. + +### GS-DIST-402 β€” If approved, build a thin, offline-capable Python launcher + +- **Depends on:** approved GS-DIST-401, GS-DIST-204. +- **Files:** separate Python packaging directory or repository, `pyproject.toml`, launcher, wheel tests, trusted-publishing workflow. +- **Scope:** build platform-specific wheels that contain the exact version-matched GuardScan executable and expose the `guardscan` console command; publish with PyPI trusted publishing; prefer `pipx install` in docs. +- **Acceptance:** installation performs no npm/npx bootstrap and no first-run download; wheel version equals GuardScan version; install, run, upgrade, uninstall, offline use, and artifact identity pass on every declared wheel target. +- **Verification:** build/check wheels, install from TestPyPI with pipx in clean environments, compare embedded binary digest with the release manifest, then rehearse yanking a test release. + +## Workstream 5: release orchestration, operations, and launch + +### GS-DIST-500 β€” Establish the reusable release automation foundation + +- **Depends on:** GS-DIST-004, GS-DIST-101. +- **Files:** focused `cli/scripts/release/` modules and tests, release-manifest schema, `.gitignore`, reusable CI workflow(s), maintainer documentation. +- **Scope:** define stable commands for `plan`, `prepare`, `validate`, `render`, `dry-run`, `publish`, `status`, and `resume`; separate pure planning/rendering/validation from credentialed mutation; keep each command deterministic and non-interactive; define a versioned release-manifest and release-state schema; make workflow YAML call these commands instead of reimplementing logic in shell fragments. +- **Acceptance:** local and CI runs produce byte-equivalent metadata from the same inputs; every command supports check-only behavior and structured output; generated files cannot drift unnoticed; release scripts are explicitly included by `.gitignore` and package/repository checks. +- **Verification:** unit and golden-fixture tests plus a workflow test that compares local and CI-generated manifest output for the same commit. + +### GS-DIST-501 β€” Replace the linear tag workflow with staged promotion + +- **Depends on:** GS-DIST-103, GS-DIST-204, GS-DIST-500. +- **Files:** split CI/release workflows, GitHub environments, release scripts. +- **Scope:** compose small reusable workflows for validation, target builds, artifact tests, signing/attestation, draft release, registry publication, adapter updates, and canaries. Use protected-tag and environment gates, a single-release concurrency lock, matrix builds for independent targets, and immutable artifact handoffs so stable promotion never rebuilds release inputs. +- **Acceptance:** version/tag mismatch, duplicate version, missing artifact, failed signature, failed smoke, failed approval, or changed artifact identity blocks promotion; reruns never overwrite a released version; channel status and artifact lineage are visible in a concise job summary. +- **Verification:** full prerelease dry run with an intentionally failed channel, concurrent release attempt, cancellation, and safe retry/resume using the original tested artifacts. + +### GS-DIST-502 β€” Harden the release supply chain + +- **Depends on:** GS-DIST-501. +- **Files/systems:** Actions workflows, npm/PyPI publisher configuration, repository settings. +- **Scope:** pin third-party actions to reviewed commit SHAs; minimize workflow permissions; use GitHub environments and OIDC; remove long-lived npm-token fallback after trusted publishing is proven; enable tag protection and immutable releases; retain provenance and SBOMs; configure grouped, reviewed updates for Actions, builders, signing tools, release dependencies, schemas, and package-manager test versions. +- **Acceptance:** release jobs have least privilege; no reusable long-lived publish secret exists where OIDC is available; workflow provenance links to the protected source commit; automated dependency updates cannot merge without the complete release-contract dry run and named-owner review. +- **Verification:** permissions review, secret inventory, provenance verification, and a release from an authorized environment only. + +### GS-DIST-503 β€” Document channel-specific rollback and incident response + +- **Depends on:** first implementation of each channel. +- **Files:** release runbook, security docs, maintainer checklist, CODEOWNERS. +- **Scope:** define npm dist-tag/deprecation response, GitHub release revocation guidance, Homebrew tap revert, Scoop manifest rollback, WinGet replacement/removal, Chocolatey unlisting/superseding, and PyPI yanking; retain historical artifacts according to policy; assign primary and backup release owners plus credential-recovery responsibility. +- **Acceptance:** each channel has primary/backup owners, rollback command/process, communication template, and maximum expected response; release automation has CODEOWNERS coverage; no plan relies on overwriting an existing version. +- **Verification:** tabletop exercise using a fake compromised/broken version and a non-production channel. + +### GS-DIST-504 β€” Publish truthful installation and support documentation + +- **Depends on:** the relevant channel acceptance gate. +- **Files:** root/CLI README, quick start, website/docs, support matrix, security verification guide, changelog. +- **Scope:** provide stable/preview labels, prerequisites, exact install/update/uninstall commands, Node requirement for JS-client installs, no-Node promise for native artifacts, checksum/attestation verification, privacy/offline expectations, and known limitations; generate the channel/support/version table and command snippets from canonical metadata where practical. +- **Acceptance:** no command is documented before its public artifact exists and passes canaries; all docs identify canonical source, version policy, and support route. +- **Verification:** automated documentation command checks plus manual copy/paste tests on clean systems. + +### GS-DIST-505 β€” Add distribution health without product telemetry + +- **Depends on:** GS-DIST-103 and native channel launch. +- **Files/systems:** scheduled CI, issue automation/status documentation. +- **Scope:** query public registry/release metadata and perform clean installs on a schedule; compare every public channel with the canonical manifest; detect version drift, broken URLs, checksum mismatches, expired signing credentials, and install failures; encode expected moderation lag, maximum version skew, severity, and reconciliation action for each channel. +- **Acceptance:** stale or broken channels create one idempotent actionable issue or update pull request at the correct severity; expected moderation lag does not create alert noise; no user machine, repository, or usage data is collected. +- **Verification:** inject fixture mismatches inside and outside each channel's allowed lag and prove detection, deduplication, escalation, and reconciliation behavior. + +### GS-DIST-506 β€” Automate downstream channel update pull requests + +- **Depends on:** GS-DIST-206, first implementation of each adapter. +- **Files/systems:** release renderers/templates, renderer fixtures, Homebrew tap/Scoop bucket/packaging repositories, documentation snippets. +- **Scope:** use GS-DIST-206 output to open reviewable downstream pull requests with machine-readable provenance back to the source release; apply channel-specific moderation/approval policy without duplicating renderer logic. +- **Acceptance:** no adapter contains a hand-copied version, URL, or checksum; `render --check` fails on drift; unchanged output creates no commit or pull request; a new channel uses the same narrow renderer/validator/smoke interface. +- **Verification:** golden tests for every renderer, native manifest validation, and a dry-run against fixture downstream repositories. + +### GS-DIST-507 β€” Make releases dry-runnable, observable, and safely resumable + +- **Depends on:** GS-DIST-500, GS-DIST-501. +- **Files/systems:** release-state schema, orchestration scripts, workflow summaries/artifacts, runbook. +- **Scope:** persist a release ledger containing commit, version, artifact digests, signatures, attestations, per-channel publication identity, approvals, and errors; add `dry-run`, `status`, and `resume` paths; query remote state before every mutation; never infer completion only from a previous job's exit code. +- **Acceptance:** maintainers can determine exactly what published and what remains from one status command; retrying a completed matching step is harmless; conflicting remote state stops with an actionable error; dry-run performs every validation without publishing. +- **Verification:** deterministic scenarios for no-op rerun, failure before publication, failure after one channel, lost CI job state, remote match, remote conflict, and successful resume. + +### GS-DIST-508 β€” Automate release-system upkeep + +- **Depends on:** GS-DIST-500, GS-DIST-502. +- **Files/systems:** dependency-update configuration, scheduled workflows, CODEOWNERS, signing/publisher inventory. +- **Scope:** automatically propose reviewed updates for Actions, release tools, package managers, schemas, and packaging templates; alert before certificate, token, or publisher-configuration expiry; assign release-code ownership; run a scheduled non-publishing rehearsal and stale-channel audit. +- **Acceptance:** automation dependencies have named owners and update cadence; release-critical updates must pass the dry-run/artifact matrix; expiring credentials or signing identities alert with enough lead time; two maintainers can execute recovery. +- **Verification:** dependency-update fixture, simulated expiry alert, CODEOWNERS review routing, and scheduled rehearsal on a synthetic version. + +### GS-DIST-509 β€” Enforce a maintainable release-automation contract + +- **Depends on:** GS-DIST-500 through GS-DIST-508. +- **Files:** release developer guide, test fixtures, contribution checklist, CI policy checks. +- **Scope:** document module boundaries and the procedure for adding a target or channel; cap shell/YAML duplication; require schema migration notes for release-manifest changes; keep platform-specific behavior inside adapters; define retention and deprecation rules for obsolete automation. +- **Acceptance:** adding a channel requires only a renderer/template, native validator, smoke test, rollback entry, and ownership metadata; core orchestration does not need channel-specific branching; all release-state/schema changes are backward-compatible or explicitly migrated. +- **Verification:** implement a no-publish fixture adapter and demonstrate that it participates in render, validate, dry-run, status, and drift checks without modifying orchestration. + +## Optional follow-on channels + +- **GHCR image:** valuable for CI and hermetic use. Run as a non-root user, support read-only repository mounts plus an explicit output mount, publish multi-architecture images by digest, and attest them. +- **Shell/PowerShell installer:** only after signed standalone assets exist. Default to a user-local directory, accept an explicit version, verify checksum/signature, and avoid `curl | sh` as the only documented path. +- **APT/RPM:** add only after usage justifies repository signing, mirror operations, distro compatibility, and long-term update maintenance. +- **Homebrew core:** pursue after the first-party tap is stable and GuardScan meets Homebrew's notability and source-build expectations. + +## Release checkpoints + +### Checkpoint A β€” Node ecosystem ready + +- GS-DIST-001 through GS-DIST-103 plus GS-DIST-500 complete. +- Release `1.1.0` or its successor to npm only after clean exact-artifact tests. +- Document npm, pnpm, Yarn, and Bun commands with accurate Node prerequisites. +- A routine Node-only release can be prepared through one release PR, exercised in dry-run mode, approved, and resumed without manually editing version or registry metadata. + +### Checkpoint B β€” Binary foundation ready + +- GS-DIST-201 through GS-DIST-206 complete. +- GitHub Release has tested, signed/attested artifacts, checksums, SBOMs, and manifest. +- No native package-manager publication before this checkpoint. + +### Checkpoint C β€” Native channels ready + +- First-party Homebrew tap and Scoop pass. +- WinGet and Chocolatey submissions follow after Windows signing and stable binary canaries. +- Channel rollback runbooks are exercised. + +### Checkpoint D β€” Optional ecosystem expansion + +- PyPI proceeds only on an approved ADR and platform-wheel prototype. +- GHCR is favored before PyPI when CI/container users have clearer demand. + +## Principal risks and controls + +| Risk | Control | +| --- | --- | +| Divergent behavior across wrappers | One implementation; exact-version binary digest checks | +| EOL runtime exposure | Raise Node floor and test only supported release lines | +| Native module/bundler failure | Builder spike; isolate optional charts; exact native smoke | +| Registry partial release | Draft/staged promotion; idempotent channel jobs; visible status | +| Release logic duplicated in YAML/shell | Thin reusable workflows backed by unit-tested release modules | +| Release cannot resume after CI loss | Persisted release ledger plus remote identity checks and idempotent steps | +| Supply-chain compromise | OIDC, least privilege, pinned actions, signatures, attestations, immutable assets | +| Package-name collision | Reserve names and document canonical publisher IDs before launch | +| Channel drift | Generated manifests from one release manifest plus scheduled canaries | +| Misleading Bun/PyPI claims | Explicit runtime labels and go/no-go decision gates | +| Unsustainable maintenance | Launch checkpoints, named owners, and rollback/upgrade tests per channel | +| Automation dependency or certificate decay | Reviewed automated updates, rehearsals, ownership, and expiry alerts | + +## Definition of multi-channel launch complete + +- A clean protected commit produced all artifacts for one version. +- Every advertised installation command has passed install, version/help, offline smoke, upgrade, and uninstall on its supported platforms. +- npm and standalone artifacts have verifiable provenance; native artifacts have checksums and platform signatures where applicable. +- All channels resolve to the same product version and artifact identity. +- Routine releases require one reviewed release PR and an explicit stable-promotion approval, not manual edits across package-manager files. +- The release can be dry-run, inspected, retried, and resumed from persisted machine-readable state without rebuilding or overwriting artifacts. +- Channel manifests and install documentation are generated and drift-checked from the canonical release manifest. +- Privacy, offline, safe-execution, SBOM, and exit-code contracts remain unchanged across channels. +- Documentation, support ownership, monitoring, and rollback are live before stable channel labels are applied. diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..219eeb8 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,81 @@ +# GuardScan distribution launch checklist + +## Implementation progress β€” local automation complete, channel acceptance pending + +- [x] Implement the exact-source RC and stable train with 30-minute reconciliation and a 24-hour machine promotion policy. +- [x] Add an append-only hash-chained ledger, idempotent remote classification, integrity incidents, and forward-fix rollback planning. +- [x] Build the full npm/native/PyPI manifest, deterministic archives and wheels, artifact SBOMs, signed checksums, and build attestations. +- [x] Implement OIDC npm/PyPI publication plus GitHub, Homebrew, Scoop, WinGet, and Chocolatey workflows. +- [x] Add hourly fail-closed canaries for every selected install contract and serialize protected ledger updates. +- [x] Add release CLI interfaces for build, manifest, publish, verify, reconcile, promote, rollback, and status. +- [x] Pass local typecheck, build, 787-test coverage suite, 53 release contracts, lint ratchet, production audit, and packed-artifact inspection. +- [ ] Complete the one-time provider/account onboarding in `docs/RELEASE_ONBOARDING.md`. +- [ ] Confirm hosted signing and cross-platform matrices, publish `1.1.0-rc.1`, and complete its 24-hour soak. +- [ ] Promote and verify `1.1.0` on every public channel; moderated registries remain pending until external acceptance. + +## Checkpoint A β€” clean npm and npm-client release + +- [ ] GS-DIST-001 split the dirty remediation branch into reviewable delivery units and verify a clean release commit. +- [ ] GS-DIST-002 raise the supported Node floor to a non-EOL line, recommended Node 22, and publish the OS/capability support policy. +- [ ] GS-DIST-003 reserve channel names and secure publisher identities with 2FA/OIDC. +- [ ] GS-DIST-004 automate one reviewable version/changelog/lockfile release PR and protected-tag creation. +- [ ] GS-DIST-101 make package smoke invoke an actual global `guardscan` shim on Linux, macOS, and Windows. +- [ ] GS-DIST-101 test optional native dependencies in full and graceful-degradation modes. +- [ ] GS-DIST-102 add pnpm global/dlx smoke. +- [ ] GS-DIST-102 add Yarn Modern dlx/project-local smoke and separate Yarn Classic legacy smoke. +- [ ] GS-DIST-102 add Bun global/bunx smoke and document whether Node is required. +- [ ] GS-DIST-103 run post-publish canaries against the exact npm version and add scheduled registry health. +- [ ] GS-DIST-500 add tested `plan`, `prepare`, `validate`, `render`, `dry-run`, `publish`, `status`, and `resume` commands plus versioned manifest/state schemas. +- [ ] GS-DIST-500 keep workflow YAML thin by moving deterministic release logic into unit-tested modules shared by local and CI runs. +- [ ] Rehearse `1.1.0` as a prerelease/dist-tag before promoting it to `latest`. + +## Checkpoint B β€” standalone release assets + +- [ ] GS-DIST-201 confirm the proposed standalone-builder ADR by passing the Node SEA host matrix; the builder and CI feasibility jobs are implemented, while Bun compile remains rejected unless product runtime compatibility is separately established. +- [ ] GS-DIST-202 make schemas, version metadata, dynamic modules, and optional charts safe for the selected builder. +- [ ] GS-DIST-203 build macOS arm64/x64, Linux arm64/x64 glibc, and Windows x64 artifacts. +- [ ] GS-DIST-204 generate a machine-readable release manifest, SHA-256 sums, per-artifact SBOMs, and attestations. +- [ ] GS-DIST-204 establish macOS signing/notarization and Windows code signing. +- [ ] GS-DIST-205 pass the exact downloaded-artifact smoke suite with Node absent. +- [ ] GS-DIST-206 build deterministic, schema-aware renderers and golden tests for every planned package adapter. +- [ ] GS-DIST-206 validate generated adapter fixtures with each ecosystem's native tooling before publication. +- [ ] Publish immutable GitHub Release assets only after all target gates pass. + +## Checkpoint C β€” native package managers + +- [ ] GS-DIST-301 create and test the first-party Homebrew tap on macOS and Linuxbrew. +- [ ] GS-DIST-302 create and test a first-party Scoop manifest/bucket. +- [ ] GS-DIST-302 validate and submit WinGet manifests after Windows artifact stability. +- [ ] GS-DIST-303 build, locally test, submit, and obtain approval for the Chocolatey package. +- [ ] Generate adapter versions, URLs, and hashes from the canonical release manifest. +- [ ] Exercise install, upgrade, uninstall, and rollback for every native channel. + +## Checkpoint D β€” optional channels + +- [ ] GS-DIST-401 decide whether Python users justify a PyPI/pipx channel. +- [ ] If approved, reserve the PyPI project and prove platform wheels on TestPyPI. +- [ ] GS-DIST-402 bundle the exact native executable in each wheel; do not bootstrap npm/npx or download on first run. +- [ ] Prefer `pipx install` in end-user docs and test install/upgrade/uninstall/yank behavior. +- [ ] Evaluate a signed multi-architecture GHCR image before APT/RPM or shell installers. + +## Cross-cutting release and operations + +- [ ] GS-DIST-501 compose reusable, matrix-based workflows with immutable artifact handoffs and a single-release concurrency lock. +- [ ] GS-DIST-502 pin release actions, minimize permissions, remove long-lived token fallback after OIDC is proven, and protect tags/releases. +- [ ] GS-DIST-503 document and rehearse rollback for npm, GitHub, Homebrew, Scoop, WinGet, Chocolatey, and optional PyPI. +- [ ] GS-DIST-504 publish only commands that resolve to tested public artifacts; label preview channels explicitly. +- [ ] GS-DIST-505 detect channel drift, broken URLs, bad checksums, signing expiry, and installation failures without end-user telemetry. +- [ ] GS-DIST-506 open reviewable, no-op-aware downstream update pull requests from GS-DIST-206 output. +- [ ] GS-DIST-507 persist a release ledger and prove dry-run, status, no-op rerun, remote-conflict detection, and partial-release resume. +- [ ] GS-DIST-508 automate reviewed release-tool/Action/template updates, ownership routing, credential-expiry alerts, and scheduled rehearsals. +- [ ] GS-DIST-509 document and test the narrow renderer/validator/smoke/rollback interface required to add a future channel. + +## Final go/no-go gate + +- [ ] One protected source commit and one version produced every advertised artifact. +- [ ] Exact-artifact install/version/help/offline-scan/SBOM/upgrade/uninstall tests pass on every supported target. +- [ ] Provenance, checksums, platform signatures, release manifest, SBOMs, and verification instructions are public. +- [ ] Channel owners, support route, incident communication, and rollback runbooks are active. +- [ ] A routine release requires one release PR plus a machine-approved 24-hour RC soak and no manual version, URL, checksum, manifest, or promotion synchronization. +- [ ] Release automation can be dry-run, inspected, safely retried, and resumed without rebuilding artifacts. +- [ ] No documentation overstates Bun-runtime, Python-native, Homebrew-core, offline, privacy, or platform support. From e1dd1695bddbfee748dc64db36f53934a15ffb30 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sat, 25 Jul 2026 23:41:22 -0400 Subject: [PATCH 02/23] feat(release): add shared channel catalog engine --- .github/release-ledger/README.md | 8 + .github/release-ledger/active-versions.json | 4 + .../contracts/release-contracts.test.ts | 51 +++ .../scripts/release-renderers.test.ts | 172 ++++++++ cli/__tests__/scripts/release-tool.test.ts | 87 ++++ cli/__tests__/scripts/release-train.test.ts | 4 +- .../guardscan.channel-catalog.v1.schema.json | 70 ++++ .../guardscan.release-event.v1.schema.json | 1 + .../guardscan.release-state.v2.schema.json | 25 ++ cli/scripts/release/events.js | 57 +++ cli/scripts/release/index.js | 101 ++++- cli/scripts/release/lib.js | 15 +- cli/scripts/release/reconcile.js | 10 +- cli/scripts/release/renderers.js | 386 +++++++++++++++++- cli/scripts/release/validators.js | 30 ++ 15 files changed, 1012 insertions(+), 9 deletions(-) create mode 100644 .github/release-ledger/README.md create mode 100644 .github/release-ledger/active-versions.json create mode 100644 cli/schemas/guardscan.channel-catalog.v1.schema.json diff --git a/.github/release-ledger/README.md b/.github/release-ledger/README.md new file mode 100644 index 0000000..bf1747a --- /dev/null +++ b/.github/release-ledger/README.md @@ -0,0 +1,8 @@ +# Release ledger bootstrap + +Use `active-versions.json` as the root file when creating the protected orphan +`release-ledger` branch. Release workflows append `events/vVERSION.jsonl` and +promotion decisions to that branch; application source never belongs there. + +The branch is an append-only evidence store. Only the installed +`guardscan-release-bot` GitHub App may write it after bootstrap. diff --git a/.github/release-ledger/active-versions.json b/.github/release-ledger/active-versions.json new file mode 100644 index 0000000..eb8a23f --- /dev/null +++ b/.github/release-ledger/active-versions.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": "guardscan.active-trains.v1", + "trains": [] +} diff --git a/cli/__tests__/contracts/release-contracts.test.ts b/cli/__tests__/contracts/release-contracts.test.ts index 9d42250..df7c362 100644 --- a/cli/__tests__/contracts/release-contracts.test.ts +++ b/cli/__tests__/contracts/release-contracts.test.ts @@ -220,6 +220,8 @@ function makeApproval(): JsonDocument { describe('release contract schemas', () => { const validateApproval = loadValidator('guardscan.release-approval.v1.schema.json'); + const validateCatalog = loadValidator('guardscan.channel-catalog.v1.schema.json'); + const validateEvent = loadValidator('guardscan.release-event.v1.schema.json'); const validateManifest = loadValidator('guardscan.release-manifest.v1.schema.json'); const validateState = loadValidator('guardscan.release-state.v1.schema.json'); @@ -235,6 +237,55 @@ describe('release contract schemas', () => { expect(validateApproval.errors).toBeNull(); }); + it('binds the shared channel catalog to GuardScan source, generator, and file digests', () => { + const catalog = { + schemaVersion: 'guardscan.channel-catalog.v1', + source: { + repository: 'ntanwir10/GuardScan', + version: '1.2.3', + tag: 'v1.2.3', + commit, + manifestUrl: 'https://github.com/ntanwir10/GuardScan/releases/download/v1.2.3/release-manifest.json', + manifestSha256: digest, + }, + generator: { + repository: 'ntanwir10/GuardScan', + commit, + }, + files: { + 'Formula/guardscan.rb': {sha256: 'c'.repeat(64)}, + 'bucket/guardscan.json': {sha256: 'd'.repeat(64)}, + }, + }; + expect(validateCatalog(catalog)).toBe(true); + expect(validateCatalog.errors).toBeNull(); + + const circular = clone(catalog) as Record; + circular.catalogCommit = commit; + expect(validateCatalog(circular)).toBe(false); + + const unknownFile = clone(catalog) as {files: Record}; + unknownFile.files['unmanaged.txt'] = {sha256: 'e'.repeat(64)}; + expect(validateCatalog(unknownFile)).toBe(false); + }); + + it('models Homebrew Core as an explicit release event channel', () => { + expect(validateEvent({ + schemaVersion: 'guardscan.release-event.v1', + version: '1.2.3', + tag: 'v1.2.3', + commit, + sequence: 2, + previousHash: 'c'.repeat(64), + timestamp, + type: 'channel_submitted', + channel: 'homebrew-core', + idempotencyKey: 'homebrew-core:v1.2.3', + payload: {}, + eventHash: 'd'.repeat(64), + })).toBe(true); + }); + it('rejects broad, malformed, and untrusted promotion approvals', () => { const wrongEnvironment = clone(makeApproval()) as { evidence: { environment: string }; diff --git a/cli/__tests__/scripts/release-renderers.test.ts b/cli/__tests__/scripts/release-renderers.test.ts index f0a7d4d..6329992 100644 --- a/cli/__tests__/scripts/release-renderers.test.ts +++ b/cli/__tests__/scripts/release-renderers.test.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -5,8 +6,11 @@ import yaml from 'js-yaml'; const { MARKER_FILE, + classifyChannelCatalog, renderAdapters, + renderChannelCatalog, toPep440, + writeChannelCatalogOutput, writeRenderedOutput, } = require('../../scripts/release/renderers') as { MARKER_FILE: string; @@ -14,7 +18,20 @@ const { manifest: Record, channels?: string ) => {channels: string[]; files: Record}; + renderChannelCatalog: ( + manifest: Record, + options: Record + ) => {files: Record; lock: Record}; + classifyChannelCatalog: ( + rendered: {files: Record; lock: Record}, + catalogRoot: string + ) => Record; toPep440: (version: string) => string; + writeChannelCatalogOutput: ( + rendered: {files: Record; lock: Record}, + outputDir: string, + checkOnly?: boolean + ) => {changed: boolean; checked: boolean; files: string[]; lockSha256: string}; writeRenderedOutput: ( manifest: Record, rendered: {channels: string[]; files: Record}, @@ -140,6 +157,13 @@ function makeManifest(): Record { } describe('release adapter rendering', () => { + const catalogOptions = { + manifestUrl: 'https://github.com/ntanwir10/GuardScan/releases/download/v1.2.3/release-manifest.json', + manifestSha256: '9'.repeat(64), + generatorRepository: 'ntanwir10/GuardScan', + generatorCommit: commit, + }; + it('renders deterministic native adapters and a fail-closed PyPI publication descriptor', () => { const manifest = makeManifest(); const rendered = renderAdapters(manifest, 'pypi,chocolatey,winget,scoop,homebrew'); @@ -194,6 +218,154 @@ describe('release adapter rendering', () => { expect(() => renderAdapters(noWheels, 'pypi')).toThrow(/requires at least one prebuilt/); }); + it('renders one cryptographically bound shared Homebrew and Scoop catalog', () => { + const rendered = renderChannelCatalog(makeManifest(), catalogOptions); + expect(Object.keys(rendered.files).sort()).toEqual([ + 'Formula/guardscan.rb', + 'bucket/guardscan.json', + 'channel-lock.json', + ]); + expect(rendered.lock).toMatchObject({ + schemaVersion: 'guardscan.channel-catalog.v1', + source: { + repository: 'ntanwir10/GuardScan', + version: '1.2.3', + tag: 'v1.2.3', + commit, + manifestSha256: '9'.repeat(64), + }, + generator: {repository: 'ntanwir10/GuardScan', commit}, + }); + expect(rendered.lock).not.toHaveProperty('catalogCommit'); + for (const catalogPath of ['Formula/guardscan.rb', 'bucket/guardscan.json']) { + expect(rendered.lock.files[catalogPath].sha256).toBe( + crypto.createHash('sha256').update(rendered.files[catalogPath]).digest('hex') + ); + } + }); + + it('detects missing, older, newer, and manually edited catalog projections', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-catalog-')); + try { + const rendered = renderChannelCatalog(makeManifest(), catalogOptions); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'missing', + integrityIncident: false, + action: 'open-or-reuse-update-pr', + }); + expect(writeChannelCatalogOutput(rendered, root)).toMatchObject({changed: true}); + expect(writeChannelCatalogOutput(rendered, root, true)).toMatchObject({ + changed: false, + checked: true, + }); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'exact', + integrityIncident: false, + action: 'record-verified', + }); + + fs.writeFileSync(path.join(root, 'Formula/evil.rb'), 'system "curl", "https://evil.invalid"\n'); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'digest-conflict', + integrityIncident: true, + action: 'stop', + }); + expect(() => writeChannelCatalogOutput(rendered, root, true)).toThrow(/unmanaged generated paths/); + fs.rmSync(path.join(root, 'Formula/evil.rb')); + + fs.appendFileSync(path.join(root, 'Formula/guardscan.rb'), '# human drift\n'); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'digest-conflict', + integrityIncident: true, + action: 'stop', + }); + expect(() => writeChannelCatalogOutput(rendered, root, true)).toThrow(/manually edited/); + + writeChannelCatalogOutput(rendered, root); + const lockPath = path.join(root, 'channel-lock.json'); + const newer = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + newer.source.version = '9.0.0'; + newer.source.tag = 'v9.0.0'; + newer.source.manifestUrl = + 'https://github.com/ntanwir10/GuardScan/releases/download/v9.0.0/release-manifest.json'; + fs.writeFileSync(lockPath, `${JSON.stringify(newer, null, 2)}\n`); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'unexpected-newer', + integrityIncident: true, + action: 'stop', + }); + + const older = { + ...newer, + source: { + ...newer.source, + version: '1.2.2', + tag: 'v1.2.2', + manifestUrl: + 'https://github.com/ntanwir10/GuardScan/releases/download/v1.2.2/release-manifest.json', + }, + }; + fs.writeFileSync(lockPath, `${JSON.stringify(older, null, 2)}\n`); + expect(classifyChannelCatalog(rendered, root)).toMatchObject({ + classification: 'older', + integrityIncident: false, + action: 'open-or-reuse-update-pr', + }); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('rejects catalog identity drift and renders a stable source formula for Homebrew Core', () => { + expect(() => renderChannelCatalog(makeManifest(), { + ...catalogOptions, + generatorCommit: 'b'.repeat(40), + })).toThrow(/exact release source commit/); + expect(() => renderChannelCatalog(makeManifest(), { + ...catalogOptions, + manifestUrl: 'https://github.com/ntanwir10/GuardScan/releases/latest/download/release-manifest.json', + })).toThrow(/canonical immutable/); + + const manifest = makeManifest(); + manifest.artifacts.push({ + id: 'npm:guardscan@1.2.3', + kind: 'npm-tarball', + filename: 'guardscan-1.2.3.tgz', + size: 1024, + sha256: '8'.repeat(64), + source, + integrity: `sha512-${'A'.repeat(86)}==`, + url: 'https://registry.npmjs.org/guardscan/-/guardscan-1.2.3.tgz', + provenance: { + type: 'slsa', + url: 'https://github.com/ntanwir10/GuardScan/attestations/npm', + verified: true, + }, + }); + const core = renderAdapters(manifest, 'homebrew-core'); + expect(core.files['homebrew-core/Formula/guardscan.rb']).toContain('depends_on "node"'); + expect(core.files['homebrew-core/Formula/guardscan.rb']).toContain('*std_npm_args'); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-homebrew-core-')); + try { + const output = path.join(root, 'adapters'); + writeRenderedOutput(manifest, core, output); + expect(validateAdapters(manifest, output, 'homebrew-core')).toMatchObject({ + valid: true, + structural: {'homebrew-core': {valid: true, files: 1}}, + }); + expect(nativeValidationPlan(output, ['homebrew-core'], 'darwin')).toEqual([ + expect.objectContaining({ + channel: 'homebrew-core', + command: 'brew', + args: expect.arrayContaining(['audit', '--new-formula']), + }), + ]); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + it('writes owned output atomically, detects drift, and refuses unmanaged replacement', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-render-')); try { diff --git a/cli/__tests__/scripts/release-tool.test.ts b/cli/__tests__/scripts/release-tool.test.ts index 67c2e58..d44280e 100644 --- a/cli/__tests__/scripts/release-tool.test.ts +++ b/cli/__tests__/scripts/release-tool.test.ts @@ -61,6 +61,19 @@ const { nextState: Record ) => void; }; +const { + createEvent, + materializeReleaseState, +} = require('../../scripts/release/events') as { + createEvent: ( + input: Record, + previous?: Record + ) => Record; + materializeReleaseState: (events: Array>) => Record; +}; +const {reconcileRelease} = require('../../scripts/release/reconcile') as { + reconcileRelease: (state: Record) => Record; +}; const COMMIT = 'a'.repeat(40); let root: string; @@ -134,6 +147,10 @@ describe('release planning and state summaries', () => { expect(channels.find(channel => channel.id === 'npm')?.status).toBe('planned'); expect(channels.find(channel => channel.id === 'github')?.status).toBe('deferred'); expect(channels.find(channel => channel.id === 'pypi')?.status).toBe('deferred'); + expect(channels.find(channel => channel.id === 'homebrew-core')).toMatchObject({ + status: 'deferred', + required: false, + }); }); it('reports failed and remaining channels without treating them as complete', () => { @@ -163,6 +180,76 @@ describe('release planning and state summaries', () => { expect(Object.keys(state.channels)).toEqual(['npm', 'github']); }); + it('tracks optional Homebrew Core work without blocking release completion', () => { + expect(reconcileRelease({ + incidents: {}, + channels: { + npm: {status: 'verified'}, + homebrew: {status: 'verified'}, + scoop: {status: 'verified'}, + 'homebrew-core': {status: 'submitted'}, + }, + })).toMatchObject({ + complete: true, + blocked: false, + blocking: [], + actions: [{ + channel: 'homebrew-core', + required: false, + currentStatus: 'submitted', + action: 'poll-acceptance', + }], + }); + }); + + it('materializes catalog publication evidence and its immutable remote identity', () => { + const first = createEvent({ + version: '1.2.3', + tag: 'v1.2.3', + commit: COMMIT, + timestamp: '2026-07-20T12:00:00.000Z', + type: 'train_started', + idempotencyKey: 'train:v1.2.3', + payload: {channels: ['homebrew']}, + }); + const catalogCommit = 'c'.repeat(40); + const fileDigest = 'd'.repeat(64); + const second = createEvent({ + version: '1.2.3', + tag: 'v1.2.3', + commit: COMMIT, + timestamp: '2026-07-20T12:01:00.000Z', + type: 'channel_verified', + channel: 'homebrew', + idempotencyKey: 'catalog:homebrew:v1.2.3', + payload: { + remoteIdentity: `github:ntanwir10/homebrew-tap@${catalogCommit}#Formula/guardscan.rb`, + remoteDigest: fileDigest, + catalog: { + repository: 'ntanwir10/homebrew-tap', + commit: catalogCommit, + pullRequest: 42, + lockDigest: 'e'.repeat(64), + manifestDigest: 'f'.repeat(64), + path: 'Formula/guardscan.rb', + fileDigest, + }, + }, + }, first); + expect(materializeReleaseState([first, second]).channels.homebrew).toMatchObject({ + status: 'verified', + remoteIdentity: `github:ntanwir10/homebrew-tap@${catalogCommit}#Formula/guardscan.rb`, + remoteDigest: fileDigest, + catalog: { + repository: 'ntanwir10/homebrew-tap', + commit: catalogCommit, + pullRequest: 42, + path: 'Formula/guardscan.rb', + fileDigest, + }, + }); + }); + it('prepares one atomic ledger and treats an identical retry as a no-op', () => { const source = validateSource({packageRoot, repositoryRoot: root, commit: COMMIT}); const outputDir = path.join(root, 'evidence', 'v1.2.3'); diff --git a/cli/__tests__/scripts/release-train.test.ts b/cli/__tests__/scripts/release-train.test.ts index ef7a915..bbb61ce 100644 --- a/cli/__tests__/scripts/release-train.test.ts +++ b/cli/__tests__/scripts/release-train.test.ts @@ -215,8 +215,8 @@ describe('append-only release train', () => { expect(reconcileRelease(state)).toMatchObject({ complete: false, actions: expect.arrayContaining([ - {channel: 'github', currentStatus: 'planned', action: 'publish'}, - {channel: 'winget', currentStatus: 'planned', action: 'submit'}, + {channel: 'github', currentStatus: 'planned', action: 'publish', required: true}, + {channel: 'winget', currentStatus: 'planned', action: 'submit', required: true}, ]), }); expect(planRollback(state)).toMatchObject({ diff --git a/cli/schemas/guardscan.channel-catalog.v1.schema.json b/cli/schemas/guardscan.channel-catalog.v1.schema.json new file mode 100644 index 0000000..412a4cb --- /dev/null +++ b/cli/schemas/guardscan.channel-catalog.v1.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://guardscancli.com/schemas/guardscan.channel-catalog.v1.schema.json", + "title": "GuardScan generated shared channel catalog lock", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "source", "generator", "files"], + "properties": { + "schemaVersion": { "const": "guardscan.channel-catalog.v1" }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "version", + "tag", + "commit", + "manifestUrl", + "manifestSha256" + ], + "properties": { + "repository": { "const": "ntanwir10/GuardScan" }, + "version": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$" + }, + "tag": { + "type": "string", + "pattern": "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$" + }, + "commit": { "$ref": "#/definitions/commit" }, + "manifestUrl": { + "type": "string", + "pattern": "^https://github\\.com/ntanwir10/GuardScan/releases/download/v[^/]+/release-manifest\\.json$" + }, + "manifestSha256": { "$ref": "#/definitions/sha256" } + } + }, + "generator": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "commit"], + "properties": { + "repository": { "const": "ntanwir10/GuardScan" }, + "commit": { "$ref": "#/definitions/commit" } + } + }, + "files": { + "type": "object", + "additionalProperties": false, + "required": ["Formula/guardscan.rb", "bucket/guardscan.json"], + "properties": { + "Formula/guardscan.rb": { "$ref": "#/definitions/file" }, + "bucket/guardscan.json": { "$ref": "#/definitions/file" } + } + } + }, + "definitions": { + "commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "file": { + "type": "object", + "additionalProperties": false, + "required": ["sha256"], + "properties": { + "sha256": { "$ref": "#/definitions/sha256" } + } + } + } +} diff --git a/cli/schemas/guardscan.release-event.v1.schema.json b/cli/schemas/guardscan.release-event.v1.schema.json index d0c82b0..6962896 100644 --- a/cli/schemas/guardscan.release-event.v1.schema.json +++ b/cli/schemas/guardscan.release-event.v1.schema.json @@ -58,6 +58,7 @@ "bun", "github", "homebrew", + "homebrew-core", "scoop", "winget", "chocolatey", diff --git a/cli/schemas/guardscan.release-state.v2.schema.json b/cli/schemas/guardscan.release-state.v2.schema.json index 358969f..dab10ae 100644 --- a/cli/schemas/guardscan.release-state.v2.schema.json +++ b/cli/schemas/guardscan.release-state.v2.schema.json @@ -69,9 +69,34 @@ "updatedAt": { "type": "string", "format": "date-time" }, "remoteIdentity": { "type": "string", "minLength": 1, "maxLength": 500 }, "remoteDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "catalog": { "$ref": "#/definitions/catalogEvidence" }, "error": { "type": "string", "minLength": 1, "maxLength": 2000 } } }, + "catalogEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "commit", + "pullRequest", + "lockDigest", + "manifestDigest", + "path", + "fileDigest" + ], + "properties": { + "repository": { "const": "ntanwir10/homebrew-tap" }, + "commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "pullRequest": { "type": "integer", "minimum": 1 }, + "lockDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "manifestDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "path": { + "enum": ["Formula/guardscan.rb", "bucket/guardscan.json"] + }, + "fileDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + }, "canary": { "type": "object", "additionalProperties": false, diff --git a/cli/scripts/release/events.js b/cli/scripts/release/events.js index a3d4b4e..8f7c047 100644 --- a/cli/scripts/release/events.js +++ b/cli/scripts/release/events.js @@ -7,6 +7,7 @@ const {CHANNELS, readBounded} = require('./lib'); const EVENT_SCHEMA = 'guardscan.release-event.v1'; const MAX_EVENT_BYTES = 64 * 1024; +const CATALOG_IDENTITY_PATTERN = /^github:ntanwir10\/homebrew-tap@[a-f0-9]{40}#(?:Formula\/guardscan\.rb|bucket\/guardscan\.json)$/; const EVENT_TYPES = Object.freeze([ 'train_started', 'artifact_built', @@ -73,6 +74,58 @@ function assertIdentity(document, expected, label) { } } +function validateCatalogEvidence(event) { + const evidence = event.payload?.catalog; + if (evidence === undefined) return; + if (!['homebrew', 'scoop'].includes(event.channel)) { + throw new Error('catalog evidence is valid only for homebrew or scoop events'); + } + if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) { + throw new Error('release event catalog evidence must be an object'); + } + const keys = Object.keys(evidence).sort(); + const expectedKeys = [ + 'commit', + 'fileDigest', + 'lockDigest', + 'manifestDigest', + 'path', + 'pullRequest', + 'repository', + ].sort(); + if (keys.join('\n') !== expectedKeys.join('\n')) { + throw new Error('release event catalog evidence has unexpected or missing fields'); + } + if (evidence.repository !== 'ntanwir10/homebrew-tap') { + throw new Error('release event catalog repository is invalid'); + } + if (!/^[a-f0-9]{40}$/.test(evidence.commit || '')) { + throw new Error('release event catalog commit is invalid'); + } + if (!Number.isSafeInteger(evidence.pullRequest) || evidence.pullRequest < 1) { + throw new Error('release event catalog pull request is invalid'); + } + for (const field of ['lockDigest', 'manifestDigest', 'fileDigest']) { + if (!/^[a-f0-9]{64}$/.test(evidence[field] || '')) { + throw new Error(`release event catalog ${field} is invalid`); + } + } + const expectedPath = event.channel === 'homebrew' + ? 'Formula/guardscan.rb' + : 'bucket/guardscan.json'; + if (evidence.path !== expectedPath) { + throw new Error(`release event catalog path does not match ${event.channel}`); + } + if (!CATALOG_IDENTITY_PATTERN.test(event.payload.remoteIdentity || '') + || event.payload.remoteIdentity + !== `github:${evidence.repository}@${evidence.commit}#${evidence.path}`) { + throw new Error('release event catalog remote identity is invalid'); + } + if (event.payload.remoteDigest !== evidence.fileDigest) { + throw new Error('release event catalog remote digest does not match file evidence'); + } +} + function validateEvent(event, previous) { if (!event || typeof event !== 'object' || Array.isArray(event)) { throw new Error('release event must be an object'); @@ -98,6 +151,7 @@ function validateEvent(event, previous) { if (event.payload === null || typeof event.payload !== 'object' || Array.isArray(event.payload)) { throw new Error('release event payload must be an object'); } + validateCatalogEvidence(event); assertCanonicalTimestamp(event.timestamp, 'release event timestamp'); if (!/^[a-f0-9]{64}$/.test(event.eventHash || '') || event.eventHash !== eventDigest(event)) { @@ -277,6 +331,9 @@ function materializeReleaseState(events) { ? {remoteDigest: event.payload.remoteDigest || previous.remoteDigest} : {}), ...(event.payload.error ? {error: event.payload.error} : {}), + ...(event.payload.catalog || previous.catalog + ? {catalog: event.payload.catalog || previous.catalog} + : {}), }; } if (event.type === 'canary_recorded') { diff --git a/cli/scripts/release/index.js b/cli/scripts/release/index.js index fb7d032..436ccc7 100644 --- a/cli/scripts/release/index.js +++ b/cli/scripts/release/index.js @@ -13,7 +13,13 @@ const { validateDocument, validateSource, } = require('./lib'); -const {renderAdapters, writeRenderedOutput} = require('./renderers'); +const { + classifyChannelCatalog, + renderAdapters, + renderChannelCatalog, + writeChannelCatalogOutput, + writeRenderedOutput, +} = require('./renderers'); const { buildNpmArtifact, queryNpmRemote, @@ -58,6 +64,8 @@ const COMMANDS = new Set([ 'prepare', 'dry-run', 'render', + 'catalog', + 'catalog-status', 'validate-adapters', 'standalone-prototype', 'package', @@ -100,6 +108,8 @@ function printHelp() { ' promote Generate the machine 24-hour promotion decision', ' rollback Append rollback_started and produce a forward-fix recovery plan', ' status Materialize and summarize release state', + ' catalog Render or check the authoritative shared channel catalog', + ' catalog-status Classify shared catalog drift against the exact release source', '', 'Foundation and compatibility commands:', ' validate, plan, prepare, dry-run, render, validate-adapters', @@ -122,6 +132,10 @@ function printHelp() { ' --artifact-id ID Manifest artifact identity', ' --remote-identity ID Immutable public identity', ' --remote-digest SHA256 Observed public SHA-256', + ' --manifest-url URL Immutable GitHub release-manifest.json URL', + ' --manifest-sha256 SHA Exact release-manifest.json SHA-256', + ' --generator-repository R Repository containing the catalog renderer', + ' --generator-commit SHA Exact renderer source commit', '', ].join('\n')); } @@ -169,6 +183,36 @@ function platformFromOptions(options) { }; } +function catalogEvidence(options, manifestFile) { + if (!['homebrew', 'scoop'].includes(options.channel)) return undefined; + requireOptions('catalog publication', options, [ + 'catalogRepository', + 'catalogCommit', + 'catalogPullRequest', + 'catalogLockDigest', + 'catalogManifestDigest', + 'catalogPath', + 'catalogFileDigest', + ]); + const pullRequest = Number(options.catalogPullRequest); + if (!Number.isSafeInteger(pullRequest) || pullRequest < 1) { + throw new Error('catalog publication requires a positive --catalog-pull-request'); + } + const evidence = { + repository: options.catalogRepository, + commit: options.catalogCommit, + pullRequest, + lockDigest: options.catalogLockDigest, + manifestDigest: options.catalogManifestDigest, + path: options.catalogPath, + fileDigest: options.catalogFileDigest, + }; + if (evidence.manifestDigest !== manifestDigest(manifestFile)) { + throw new Error('catalog manifest digest does not match the exact release manifest'); + } + return evidence; +} + function hashExecutable(file) { const buffer = fs.readFileSync(path.resolve(file)); if (buffer.length <= 0) throw new Error('standalone executable is empty'); @@ -179,6 +223,27 @@ function hashExecutable(file) { }; } +function renderCatalogFromOptions(manifest, options) { + requireOptions('catalog', options, [ + 'manifest', + 'manifestUrl', + 'manifestSha256', + 'generatorRepository', + 'generatorCommit', + ]); + const manifestBytes = fs.readFileSync(path.resolve(options.manifest)); + const actualManifestSha256 = crypto.createHash('sha256').update(manifestBytes).digest('hex'); + if (actualManifestSha256 !== options.manifestSha256) { + throw new Error('catalog manifestSha256 does not match the exact manifest file'); + } + return renderChannelCatalog(manifest, { + manifestUrl: options.manifestUrl, + manifestSha256: options.manifestSha256, + generatorRepository: options.generatorRepository, + generatorCommit: options.generatorCommit, + }); +} + async function handleBuild(source, options) { requireOptions('build', options, ['kind']); if (options.kind !== 'python-wheel-artifact') requireOptions('build', options, ['outputDir']); @@ -257,8 +322,9 @@ function handlePublication(command, source, manifest, options) { ]); const artifact = manifestArtifact(manifest, options.artifactId); if (options.artifactRoot) verifyManifestFiles(manifest, options.artifactRoot); + const catalog = catalogEvidence(options, options.manifest); const classification = classifyRemoteArtifact( - {sha256: artifact.sha256}, + {sha256: catalog?.fileDigest || artifact.sha256}, {identity: options.remoteIdentity, sha256: options.remoteDigest} ); if (classification.integrityIncident) { @@ -280,6 +346,7 @@ function handlePublication(command, source, manifest, options) { artifactIds: [artifact.id], remoteIdentity: options.remoteIdentity, remoteDigest: options.remoteDigest, + ...(catalog ? {catalog} : {}), }, options.channel)); return {changed: result.changed, classification, event: result.event}; } @@ -528,6 +595,36 @@ async function main(argv) { return; } + if (command === 'catalog') { + if (!manifest) throw new Error('catalog requires --manifest'); + requireOptions('catalog', options, ['outputDir']); + const rendered = renderCatalogFromOptions(manifest, options); + const result = writeChannelCatalogOutput( + rendered, + options.outputDir, + options.check === true + ); + validateDocument('catalog', path.join(result.outputDir, 'channel-lock.json'), packageRoot); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + if (command === 'catalog-status') { + if (!manifest) throw new Error('catalog-status requires --manifest'); + requireOptions('catalog-status', options, ['catalogRoot']); + const rendered = renderCatalogFromOptions(manifest, options); + const result = classifyChannelCatalog(rendered, options.catalogRoot); + if (result.classification === 'exact') { + validateDocument( + 'catalog', + path.join(path.resolve(options.catalogRoot), 'channel-lock.json'), + packageRoot + ); + } + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + if (command === 'validate-adapters') { if (!manifest) throw new Error('validate-adapters requires --manifest'); requireOptions('validate-adapters', options, ['outputDir']); diff --git a/cli/scripts/release/lib.js b/cli/scripts/release/lib.js index 9d6be8f..270a60a 100644 --- a/cli/scripts/release/lib.js +++ b/cli/scripts/release/lib.js @@ -18,6 +18,13 @@ const CHANNELS = Object.freeze([ {id: 'bun', phase: 'node', operation: 'verify', artifacts: ['npm-tarball']}, {id: 'github', phase: 'native', operation: 'publish', artifacts: ['standalone', 'checksum', 'sbom']}, {id: 'homebrew', phase: 'full', operation: 'update-adapter', artifacts: ['standalone']}, + { + id: 'homebrew-core', + phase: 'full', + operation: 'submit', + artifacts: ['npm-tarball'], + required: false, + }, {id: 'scoop', phase: 'full', operation: 'update-adapter', artifacts: ['standalone']}, {id: 'winget', phase: 'full', operation: 'submit', artifacts: ['standalone']}, {id: 'chocolatey', phase: 'full', operation: 'publish', artifacts: ['standalone']}, @@ -137,6 +144,7 @@ function createPlan(source, profile = 'node') { phase: channel.phase, operation: channel.operation, artifacts: [...channel.artifacts], + required: channel.required !== false, status: PROFILE_ORDER[channel.phase] <= PROFILE_ORDER[profile] ? 'planned' : 'deferred', })); const gates = [ @@ -181,7 +189,9 @@ function createInitialState(source, profile, timestamp) { if (!(profile in PROFILE_ORDER)) throw new Error(`Unknown release profile: ${profile}`); const normalizedTimestamp = new Date(timestamp).toISOString(); const publicationChannels = CHANNELS.filter(channel => ( - channel.operation !== 'verify' && PROFILE_ORDER[channel.phase] <= PROFILE_ORDER[profile] + channel.operation !== 'verify' + && channel.required !== false + && PROFILE_ORDER[channel.phase] <= PROFILE_ORDER[profile] )); return { schemaVersion: 'guardscan.release-state.v1', @@ -241,6 +251,7 @@ function validateDocument(kind, file, packageRoot) { const document = readJson(path.resolve(file), kind); const schemaNames = { approval: 'guardscan.release-approval.v1.schema.json', + catalog: 'guardscan.channel-catalog.v1.schema.json', decision: 'guardscan.promotion-decision.v1.schema.json', event: 'guardscan.release-event.v1.schema.json', manifest: 'guardscan.release-manifest.v1.schema.json', @@ -260,7 +271,7 @@ function validateDocument(kind, file, packageRoot) { } function assertInternalDocumentIdentity(kind, document) { - if (kind === 'decision') return; + if (kind === 'decision' || kind === 'catalog') return; if (document.tag !== `v${document.version}`) { throw new Error(`${kind} tag does not match its version`); } diff --git a/cli/scripts/release/reconcile.js b/cli/scripts/release/reconcile.js index 8cda44e..cbd3bbf 100644 --- a/cli/scripts/release/reconcile.js +++ b/cli/scripts/release/reconcile.js @@ -12,19 +12,23 @@ function channelOperation(channel) { function reconcileRelease(state) { const actions = []; const blocking = []; + const optionalBlocking = []; const openIncidents = Object.entries(state.incidents || {}) .filter(([, incident]) => incident.status === 'open') .map(([incidentId]) => incidentId); if (openIncidents.length > 0) blocking.push(`open incidents: ${openIncidents.join(', ')}`); for (const [channel, channelState] of Object.entries(state.channels || {})) { + const definition = CHANNELS.find(candidate => candidate.id === channel); const operation = channelOperation(channel); + const required = definition.required !== false; if (['verified', 'withdrawn', 'superseded'].includes(channelState.status)) continue; if (channelState.status === 'failed') { - blocking.push(`${channel} failed`); + (required ? blocking : optionalBlocking).push(`${channel} failed`); continue; } const action = { channel, + required, currentStatus: channelState.status, action: 'verify', }; @@ -40,9 +44,10 @@ function reconcileRelease(state) { actions.push(action); } return { - complete: actions.length === 0 && blocking.length === 0, + complete: actions.every(action => action.required === false) && blocking.length === 0, blocked: blocking.length > 0, blocking, + optionalBlocking, actions: actions.sort((a, b) => a.channel.localeCompare(b.channel)), }; } @@ -71,6 +76,7 @@ function planRollback(state, knownGoodVersion) { bun: 'verify-npm-forward-fix', pypi: 'yank-and-forward-fix', homebrew: knownGoodVersion ? 'redirect-to-known-good' : 'remove-new-listing', + 'homebrew-core': 'submit-corrective-formula-or-revision', scoop: knownGoodVersion ? 'redirect-to-known-good' : 'remove-new-listing', winget: 'submit-corrective-manifest', chocolatey: 'unlist-or-supersede', diff --git a/cli/scripts/release/renderers.js b/cli/scripts/release/renderers.js index 9da4673..327f430 100644 --- a/cli/scripts/release/renderers.js +++ b/cli/scripts/release/renderers.js @@ -8,9 +8,24 @@ const {readJson} = require('./lib'); const RENDER_SCHEMA = 'guardscan.release-render.v1'; const MARKER_FILE = '.guardscan-release-render.json'; -const SUPPORTED_CHANNELS = Object.freeze(['homebrew', 'scoop', 'winget', 'chocolatey', 'pypi']); +const CATALOG_SCHEMA = 'guardscan.channel-catalog.v1'; +const CATALOG_FILES = Object.freeze([ + 'Formula/guardscan.rb', + 'bucket/guardscan.json', +]); +const SUPPORTED_CHANNELS = Object.freeze([ + 'homebrew', + 'homebrew-core', + 'scoop', + 'winget', + 'chocolatey', + 'pypi', +]); const DEFAULT_CHANNELS = Object.freeze(['homebrew', 'scoop', 'winget', 'chocolatey', 'pypi']); const MAX_RENDERED_FILES = 100; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const COMMIT_PATTERN = /^[a-f0-9]{40}$/; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; function parseChannels(value) { const channels = value @@ -71,6 +86,10 @@ function isSecureUrl(value) { } } +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + function selectStandalone(manifest, os, arch, libc) { const matches = manifest.artifacts.filter(artifact => ( artifact.kind === 'standalone' @@ -140,6 +159,54 @@ function renderHomebrew(manifest, artifacts) { ].join('\n'); } +function selectNpmTarball(manifest) { + const matches = manifest.artifacts.filter(artifact => artifact.kind === 'npm-tarball'); + if (matches.length !== 1) { + throw new Error('Homebrew Core rendering requires exactly one npm-tarball artifact'); + } + const artifact = matches[0]; + const expectedUrl = `https://registry.npmjs.org/guardscan/-/guardscan-${manifest.version}.tgz`; + if (artifact.url !== expectedUrl) { + throw new Error('Homebrew Core npm tarball URL is not the canonical versioned registry URL'); + } + if (!SHA256_PATTERN.test(artifact.sha256 || '')) { + throw new Error('Homebrew Core npm tarball has an invalid SHA-256'); + } + if (!artifact.integrity || artifact.provenance?.verified !== true + || !isSecureUrl(artifact.provenance.url)) { + throw new Error('Homebrew Core npm tarball lacks verified provenance'); + } + return artifact; +} + +function renderHomebrewCore(manifest) { + if (semver.prerelease(manifest.version)) { + throw new Error('Homebrew Core rendering is supported only for stable releases'); + } + const artifact = selectNpmTarball(manifest); + return [ + '# Generated by GuardScan release automation for submission to homebrew/core.', + 'class Guardscan < Formula', + ' desc "Privacy-first code review and security scanning CLI"', + ' homepage "https://guardscancli.com"', + ` url "${artifact.url}"`, + ` sha256 "${artifact.sha256}"`, + ' license "MIT"', + '', + ' depends_on "node"', + '', + ' def install', + ' system "npm", "install", *std_npm_args', + ' end', + '', + ' test do', + ' assert_match version.to_s, shell_output("#{bin}/guardscan --version")', + ' end', + 'end', + '', + ].join('\n'); +} + function renderScoop(manifest, artifact) { return `${JSON.stringify({ version: manifest.version, @@ -304,6 +371,9 @@ function renderAdapters(manifest, channelInput) { if (channels.includes('homebrew')) { files['homebrew/Formula/guardscan.rb'] = renderHomebrew(manifest, artifacts); } + if (channels.includes('homebrew-core')) { + files['homebrew-core/Formula/guardscan.rb'] = renderHomebrewCore(manifest); + } if (channels.includes('scoop')) { files['scoop/bucket/guardscan.json'] = renderScoop(manifest, artifacts.windowsX64); } @@ -313,6 +383,315 @@ function renderAdapters(manifest, channelInput) { return {channels, files}; } +function validateCatalogOptions(manifest, options = {}) { + const manifestUrl = String(options.manifestUrl || ''); + const manifestSha256 = String(options.manifestSha256 || ''); + const generatorRepository = String(options.generatorRepository || ''); + const generatorCommit = String(options.generatorCommit || '').toLowerCase(); + if (!isSecureUrl(manifestUrl)) throw new Error('catalog manifestUrl must be a secure HTTPS URL'); + const expectedManifestUrl = `https://github.com/ntanwir10/GuardScan/releases/download/${manifest.tag}/release-manifest.json`; + if (manifestUrl !== expectedManifestUrl) { + throw new Error('catalog manifestUrl is not the canonical immutable GuardScan release URL'); + } + if (!SHA256_PATTERN.test(manifestSha256)) { + throw new Error('catalog manifestSha256 must be a lowercase SHA-256'); + } + if (!REPOSITORY_PATTERN.test(generatorRepository) + || generatorRepository !== 'ntanwir10/GuardScan') { + throw new Error('catalog generator repository must be ntanwir10/GuardScan'); + } + if (!COMMIT_PATTERN.test(generatorCommit)) { + throw new Error('catalog generator commit must be a lowercase 40-character SHA'); + } + if (generatorCommit !== manifest.commit) { + throw new Error('catalog generator commit must match the exact release source commit'); + } + return {manifestUrl, manifestSha256, generatorRepository, generatorCommit}; +} + +function renderChannelCatalog(manifest, options = {}) { + const catalogOptions = validateCatalogOptions(manifest, options); + const artifacts = selectNativeArtifacts(manifest, ['homebrew', 'scoop']); + const files = { + 'Formula/guardscan.rb': renderHomebrew(manifest, artifacts), + 'bucket/guardscan.json': renderScoop(manifest, artifacts.windowsX64), + }; + const lock = { + schemaVersion: CATALOG_SCHEMA, + source: { + repository: 'ntanwir10/GuardScan', + version: manifest.version, + tag: manifest.tag, + commit: manifest.commit, + manifestUrl: catalogOptions.manifestUrl, + manifestSha256: catalogOptions.manifestSha256, + }, + generator: { + repository: catalogOptions.generatorRepository, + commit: catalogOptions.generatorCommit, + }, + files: Object.fromEntries(CATALOG_FILES.map(file => [ + file, + {sha256: sha256(files[file])}, + ])), + }; + return { + files: { + ...files, + 'channel-lock.json': `${JSON.stringify(lock, null, 2)}\n`, + }, + lock, + }; +} + +function assertSafeCatalogTarget(root, relative) { + let current = root; + if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) { + throw new Error(`catalog output root must not be a symbolic link: ${root}`); + } + for (const segment of relative.split('/')) { + current = path.join(current, segment); + if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) { + throw new Error(`catalog output contains a symbolic link: ${relative}`); + } + } +} + +function listManagedCatalogFiles(outputDir) { + const files = []; + function visit(directory, relative) { + if (!fs.existsSync(directory)) return; + const stat = fs.lstatSync(directory); + if (stat.isSymbolicLink()) { + throw new Error(`catalog managed path contains a symbolic link: ${relative}`); + } + if (!stat.isDirectory()) { + throw new Error(`catalog managed path is not a directory: ${relative}`); + } + for (const entry of fs.readdirSync(directory, {withFileTypes: true})) { + const nextRelative = `${relative}/${entry.name}`; + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`catalog managed path contains a symbolic link: ${nextRelative}`); + } + if (entry.isDirectory()) visit(absolute, nextRelative); + else if (entry.isFile()) files.push(nextRelative); + else throw new Error(`catalog managed path contains a non-regular entry: ${nextRelative}`); + } + } + for (const directory of ['Formula', 'bucket']) { + visit(path.join(outputDir, directory), directory); + } + return files.sort(); +} + +function compareCatalogOutput(outputDir, rendered) { + const managedFiles = listManagedCatalogFiles(outputDir); + if (managedFiles.join('\n') !== [...CATALOG_FILES].sort().join('\n')) return false; + return Object.entries(rendered.files).every(([relative, contents]) => { + assertSafeCatalogTarget(outputDir, relative); + const target = path.join(outputDir, ...relative.split('/')); + return fs.existsSync(target) + && fs.statSync(target).isFile() + && fs.readFileSync(target, 'utf8') === contents; + }); +} + +function writeChannelCatalogOutput(rendered, outputDir, checkOnly = false) { + const resolved = path.resolve(outputDir); + if (path.dirname(resolved) === resolved) { + throw new Error('refusing to use a filesystem root as catalog output'); + } + const unexpectedManagedFiles = listManagedCatalogFiles(resolved) + .filter(file => !CATALOG_FILES.includes(file)); + if (unexpectedManagedFiles.length > 0) { + throw new Error( + `refusing to update catalog with unmanaged generated paths: ${unexpectedManagedFiles.join(', ')}` + ); + } + if (compareCatalogOutput(resolved, rendered)) { + return { + changed: false, + checked: checkOnly, + outputDir: resolved, + files: Object.keys(rendered.files).sort(), + lockSha256: sha256(rendered.files['channel-lock.json']), + }; + } + if (checkOnly) throw new Error(`channel catalog is missing, stale, or manually edited: ${resolved}`); + fs.mkdirSync(resolved, {recursive: true, mode: 0o700}); + const staged = []; + try { + for (const [relative, contents] of Object.entries(rendered.files)) { + assertSafeCatalogTarget(resolved, relative); + const target = path.join(resolved, ...relative.split('/')); + fs.mkdirSync(path.dirname(target), {recursive: true, mode: 0o700}); + const stage = `${target}.guardscan-${process.pid}-${crypto.randomUUID()}.tmp`; + fs.writeFileSync(stage, contents, {encoding: 'utf8', mode: 0o600, flag: 'wx'}); + staged.push({stage, target}); + } + for (const entry of staged) fs.renameSync(entry.stage, entry.target); + } finally { + for (const entry of staged) fs.rmSync(entry.stage, {force: true}); + } + return { + changed: true, + checked: false, + outputDir: resolved, + files: Object.keys(rendered.files).sort(), + lockSha256: sha256(rendered.files['channel-lock.json']), + }; +} + +function readCatalogLock(catalogRoot) { + const resolved = path.resolve(catalogRoot); + const lockFile = path.join(resolved, 'channel-lock.json'); + if (!fs.existsSync(lockFile)) return undefined; + assertSafeCatalogTarget(resolved, 'channel-lock.json'); + const text = fs.readFileSync(lockFile, 'utf8'); + return {document: readJson(lockFile, 'channel catalog lock'), text}; +} + +function hasExactKeys(value, keys) { + return value && typeof value === 'object' && !Array.isArray(value) + && Object.keys(value).sort().join('\n') === [...keys].sort().join('\n'); +} + +function isSelfConsistentCatalog(lock, catalogRoot) { + try { + if (!hasExactKeys(lock, ['schemaVersion', 'source', 'generator', 'files']) + || lock.schemaVersion !== CATALOG_SCHEMA + || !hasExactKeys(lock.source, [ + 'repository', + 'version', + 'tag', + 'commit', + 'manifestUrl', + 'manifestSha256', + ]) + || !hasExactKeys(lock.generator, ['repository', 'commit']) + || !hasExactKeys(lock.files, CATALOG_FILES)) { + return false; + } + if (lock.source.repository !== 'ntanwir10/GuardScan' + || lock.generator.repository !== 'ntanwir10/GuardScan' + || !COMMIT_PATTERN.test(lock.source.commit || '') + || lock.generator.commit !== lock.source.commit + || lock.source.tag !== `v${lock.source.version}` + || lock.source.manifestUrl + !== `https://github.com/ntanwir10/GuardScan/releases/download/${lock.source.tag}/release-manifest.json` + || !SHA256_PATTERN.test(lock.source.manifestSha256 || '')) { + return false; + } + const managedFiles = listManagedCatalogFiles(path.resolve(catalogRoot)); + if (managedFiles.join('\n') !== [...CATALOG_FILES].sort().join('\n')) return false; + for (const relative of CATALOG_FILES) { + if (!hasExactKeys(lock.files[relative], ['sha256']) + || !SHA256_PATTERN.test(lock.files[relative].sha256 || '')) { + return false; + } + assertSafeCatalogTarget(path.resolve(catalogRoot), relative); + const contents = fs.readFileSync(path.join(catalogRoot, ...relative.split('/'))); + if (sha256(contents) !== lock.files[relative].sha256) return false; + } + return true; + } catch { + return false; + } +} + +function classifyChannelCatalog(rendered, catalogRoot) { + const actual = readCatalogLock(catalogRoot); + const desiredLock = rendered.lock; + const desiredLockSha256 = sha256(rendered.files['channel-lock.json']); + if (!actual) { + return { + classification: 'missing', + integrityIncident: false, + action: 'open-or-reuse-update-pr', + desiredLockSha256, + }; + } + const actualLock = actual.document; + const actualLockSha256 = sha256(actual.text); + if (!actualLock || typeof actualLock !== 'object' || Array.isArray(actualLock) + || actualLock.schemaVersion !== CATALOG_SCHEMA + || !actualLock.source || !actualLock.generator || !actualLock.files) { + return { + classification: 'invalid', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + if (!isSelfConsistentCatalog(actualLock, catalogRoot)) { + return { + classification: 'digest-conflict', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + const actualVersion = semver.valid(actualLock.source.version); + const desiredVersion = semver.valid(desiredLock.source.version); + if (!actualVersion || !desiredVersion) { + return { + classification: 'invalid', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + if (semver.gt(actualVersion, desiredVersion)) { + return { + classification: 'unexpected-newer', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + if (semver.lt(actualVersion, desiredVersion)) { + return { + classification: 'older', + integrityIncident: false, + action: 'open-or-reuse-update-pr', + desiredLockSha256, + actualLockSha256, + }; + } + if (actualLock.source.tag !== desiredLock.source.tag + || actualLock.source.commit !== desiredLock.source.commit + || actualLock.source.manifestSha256 !== desiredLock.source.manifestSha256) { + return { + classification: 'release-identity-conflict', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + if (!compareCatalogOutput(path.resolve(catalogRoot), rendered)) { + return { + classification: 'digest-conflict', + integrityIncident: true, + action: 'stop', + desiredLockSha256, + actualLockSha256, + }; + } + return { + classification: 'exact', + integrityIncident: false, + action: 'record-verified', + desiredLockSha256, + actualLockSha256, + }; +} + function expectedOutput(manifest, rendered) { const paths = Object.keys(rendered.files).sort(); const marker = { @@ -410,12 +789,17 @@ function writeRenderedOutput(manifest, rendered, outputDir, checkOnly = false) { } module.exports = { + CATALOG_FILES, + CATALOG_SCHEMA, DEFAULT_CHANNELS, MARKER_FILE, RENDER_SCHEMA, SUPPORTED_CHANNELS, + classifyChannelCatalog, parseChannels, renderAdapters, + renderChannelCatalog, toPep440, + writeChannelCatalogOutput, writeRenderedOutput, }; diff --git a/cli/scripts/release/validators.js b/cli/scripts/release/validators.js index 2d21113..12b456c 100644 --- a/cli/scripts/release/validators.js +++ b/cli/scripts/release/validators.js @@ -25,6 +25,20 @@ function validateStructuredOutput(manifest, outputDir, channels) { } results.homebrew = {valid: true, files: 1}; } + if (channels.includes('homebrew-core')) { + const formula = readOutput( + outputDir, + 'homebrew-core/Formula/guardscan.rb', + 'Homebrew Core formula' + ); + const npmUrl = `https://registry.npmjs.org/guardscan/-/guardscan-${manifest.version}.tgz`; + if (!formula.includes(`url "${npmUrl}"`) + || !formula.includes('depends_on "node"') + || !formula.includes('system "npm", "install", *std_npm_args')) { + throw new Error('Homebrew Core formula does not preserve the source-build contract'); + } + results['homebrew-core'] = {valid: true, files: 1}; + } if (channels.includes('scoop')) { const scoop = JSON.parse(readOutput(outputDir, 'scoop/bucket/guardscan.json', 'Scoop manifest')); if (scoop.version !== manifest.version || scoop.bin !== 'guardscan.exe' @@ -95,6 +109,22 @@ function nativeValidationPlan(outputDir, channels, platform = process.platform, skip('homebrew', 'Homebrew validation requires macOS or Linux'); } } + if (channels.includes('homebrew-core')) { + if (platform === 'darwin' || platform === 'linux') { + plan.push({ + channel: 'homebrew-core', + command: 'brew', + args: [ + 'audit', + '--strict', + '--new-formula', + path.join(outputDir, 'homebrew-core', 'Formula', 'guardscan.rb'), + ], + }); + } else { + skip('homebrew-core', 'Homebrew Core validation requires macOS or Linux'); + } + } if (channels.includes('scoop')) { if (platform === 'win32') { plan.push({ From 41c2acba667fe0d67291c72199830e437bd1ec0a Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sat, 25 Jul 2026 23:41:43 -0400 Subject: [PATCH 03/23] ci(release): synchronize shared distribution catalog --- .github/workflows/release-build.yml | 4 +- .github/workflows/release-canary.yml | 65 ++- .github/workflows/release-please.yml | 2 +- .github/workflows/release-publish.yml | 307 +++++++++++--- .github/workflows/release-train.yml | 374 +++++++++++++++++- .../homebrew-tap/.github/workflows/verify.yml | 271 +++++++++++++ catalog/homebrew-tap/README.md | 19 + .../scripts/release-please-config.test.ts | 1 + .../scripts/release-workflows.test.ts | 105 +++++ 9 files changed, 1073 insertions(+), 75 deletions(-) create mode 100644 catalog/homebrew-tap/.github/workflows/verify.yml create mode 100644 catalog/homebrew-tap/README.md diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 120bf12..707414f 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -191,7 +191,7 @@ jobs: standalone/guardscan cosign verify-blob \ --bundle standalone/guardscan.sigstore.json \ - --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-build.yml@" \ + --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-train.yml@" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ standalone/guardscan @@ -556,7 +556,7 @@ jobs: cosign sign-blob --yes --bundle release/SHA256SUMS.sigstore.json release/SHA256SUMS cosign verify-blob \ --bundle release/SHA256SUMS.sigstore.json \ - --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-build.yml@" \ + --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-train.yml@" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ release/SHA256SUMS - name: Attest complete release payload diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index f9ac882..8bf9468 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -15,10 +15,13 @@ permissions: env: RELEASE_NODE_VERSION: 22.23.1 + SCOOP_INSTALL_COMMIT: b0ee913725139b816f9178163af0aecdba07a7ed + SCOOP_COMMIT: b588a06e41d920d2123ec70aee682bae14935939 jobs: discover: name: Discover active releases + if: github.event_name == 'workflow_dispatch' || vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 outputs: trains: ${{ steps.matrix.outputs.trains }} @@ -235,7 +238,7 @@ jobs: --dir release --pattern "$TARGET.guardscan.sigstore.json" cosign verify-blob \ --bundle "release/$TARGET.guardscan.sigstore.json" \ - --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-build.yml@" \ + --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-train.yml@" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ executable/guardscan - name: Verify macOS signing and Gatekeeper @@ -330,6 +333,26 @@ jobs: train: ${{ fromJSON(needs.discover.outputs.trains) }} adapter: [homebrew-macos, homebrew-linux, scoop] steps: + - name: Check out the pinned Scoop installer + if: matrix.adapter == 'scoop' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: ScoopInstaller/Install + ref: ${{ env.SCOOP_INSTALL_COMMIT }} + path: .scoop-installer + persist-credentials: false + - name: Install the pinned Scoop CLI + if: matrix.adapter == 'scoop' + shell: pwsh + run: | + $scoopRoot = Join-Path $env:RUNNER_TEMP 'scoop' + & .\.scoop-installer\install.ps1 -RunAsAdmin -ScoopDir $scoopRoot + if (-not $?) { throw 'Pinned Scoop installer failed' } + git -C "$scoopRoot\apps\scoop\current" fetch origin $env:SCOOP_COMMIT --depth 1 + if ($LASTEXITCODE -ne 0) { throw 'Unable to fetch the pinned Scoop CLI' } + git -C "$scoopRoot\apps\scoop\current" checkout --detach $env:SCOOP_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Unable to select the pinned Scoop CLI' } + "$scoopRoot\shims" | Out-File $env:GITHUB_PATH -Append - name: Initialize fail-closed report shell: bash run: | @@ -363,7 +386,7 @@ jobs: guardscan --version brew uninstall guardscan else - git clone --branch "guardscan/v$VERSION" \ + git clone --branch "channel-preview/v$VERSION" \ https://github.com/ntanwir10/homebrew-tap preview-tap brew style preview-tap/Formula/guardscan.rb brew audit --strict preview-tap/Formula/guardscan.rb @@ -386,15 +409,24 @@ jobs: CHANNEL: ${{ matrix.train.channel }} run: | if ($env:CHANNEL -eq 'stable') { - scoop bucket add guardscan https://github.com/ntanwir10/scoop-bucket + scoop bucket add guardscan https://github.com/ntanwir10/homebrew-tap + if ($LASTEXITCODE -ne 0) { throw 'Unable to add the stable GuardScan bucket' } + $package = 'guardscan/guardscan' } else { - git clone --branch "guardscan/v$env:VERSION" https://github.com/ntanwir10/scoop-bucket preview-bucket + git clone --branch "channel-preview/v$env:VERSION" https://github.com/ntanwir10/homebrew-tap preview-bucket + if ($LASTEXITCODE -ne 0) { throw 'Unable to clone the GuardScan preview bucket' } scoop bucket add guardscan-preview (Resolve-Path preview-bucket) + if ($LASTEXITCODE -ne 0) { throw 'Unable to add the GuardScan preview bucket' } + $package = 'guardscan-preview/guardscan' } - scoop install guardscan + scoop install $package + if ($LASTEXITCODE -ne 0) { throw 'Scoop installation failed' } guardscan --version + if ($LASTEXITCODE -ne 0) { throw 'Scoop invocation failed' } scoop update guardscan + if ($LASTEXITCODE -ne 0) { throw 'Scoop update check failed' } scoop uninstall guardscan + if ($LASTEXITCODE -ne 0) { throw 'Scoop uninstall failed' } node -e ' const fs = require("fs"); const report = JSON.parse(fs.readFileSync("report.json")); @@ -564,9 +596,9 @@ jobs: type, channel: report.channel, idempotencyKey: `${type}:${suffix}`, - payload: { - remoteIdentity: `${report.channel}:${version}`, - }, + payload: ['homebrew', 'scoop'].includes(report.channel) + ? {} + : {remoteIdentity: `${report.channel}:${version}`}, }); } } @@ -575,11 +607,26 @@ jobs: NODE git worktree add ledger-branch origin/release-ledger cp ledgers/*.jsonl ledger-branch/events/ + node - <<'NODE' + const fs = require('fs'); + const path = require('path'); + const {materializeReleaseState, readEvents} = require('./cli/scripts/release/events'); + const {reconcileRelease} = require('./cli/scripts/release/reconcile'); + const activeFile = 'ledger-branch/active-versions.json'; + const active = JSON.parse(fs.readFileSync(activeFile)); + active.trains = (active.trains || []).filter(train => { + if (train.channel !== 'stable') return true; + const ledger = path.join('ledger-branch/events', `v${train.version}.jsonl`); + return !fs.existsSync(ledger) + || !reconcileRelease(materializeReleaseState(readEvents(ledger))).complete; + }); + fs.writeFileSync(activeFile, `${JSON.stringify(active, null, 2)}\n`); + NODE ( cd ledger-branch git config user.name guardscan-release-bot git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add events + git add events active-versions.json git commit -m "canary evidence: run $GITHUB_RUN_ID" git push origin HEAD:release-ledger ) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 34e6572..4d7df28 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,7 +15,7 @@ permissions: jobs: release-pr: name: Update release pull request - if: ${{ vars.RELEASE_APP_ID != '' }} + if: ${{ vars.RELEASE_AUTOMATION_ENABLED == 'true' && vars.RELEASE_APP_ID != '' }} runs-on: ubuntu-latest steps: - name: Create short-lived release app token diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 6fec18c..016df6a 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -18,10 +18,12 @@ permissions: env: RELEASE_NODE_VERSION: 22.23.1 + RELEASE_NPM_VERSION: 11.5.2 jobs: github: name: Immutable GitHub release + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 environment: ${{ inputs.channel == 'rc' && 'release-rc' || 'release-stable' }} permissions: @@ -81,7 +83,7 @@ jobs: (cd redownload && sha256sum --check SHA256SUMS) cosign verify-blob \ --bundle redownload/SHA256SUMS.sigstore.json \ - --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-build.yml@" \ + --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-train.yml@" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ redownload/SHA256SUMS - name: Publish verified draft @@ -94,6 +96,7 @@ jobs: npm: name: npm trusted publication + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 needs: [github] environment: npm-publish @@ -113,6 +116,10 @@ jobs: with: node-version: ${{ env.RELEASE_NODE_VERSION }} registry-url: https://registry.npmjs.org + - name: Install trusted-publishing-capable npm CLI + run: | + npm install --global "npm@${RELEASE_NPM_VERSION}" + test "$(npm --version)" = "$RELEASE_NPM_VERSION" - working-directory: cli run: npm ci - name: Verify and classify registry state @@ -133,6 +140,7 @@ jobs: pypi-test: name: TestPyPI trusted publication and lifecycle + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 needs: [github] environment: pypi @@ -144,10 +152,10 @@ jobs: with: name: release-payload-${{ inputs.tag }} path: payload - - name: Select Linux x64 wheel + - name: Select every tested platform wheel run: | mkdir dist - cp payload/*manylinux_2_28_x86_64.whl dist/ + cp payload/*.whl dist/ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.13" @@ -169,10 +177,16 @@ jobs: raise remote = {"urls": []} remote_files = {item["filename"]: item["digests"]["sha256"] for item in remote["urls"]} - for wheel in pathlib.Path("dist").glob("*.whl"): - digest = hashlib.sha256(wheel.read_bytes()).hexdigest() - if wheel.name in remote_files and remote_files[wheel.name] != digest: - raise SystemExit(f"TestPyPI integrity conflict for {wheel.name}") + local_files = { + wheel.name: hashlib.sha256(wheel.read_bytes()).hexdigest() + for wheel in pathlib.Path("dist").glob("*.whl") + } + unexpected = sorted(set(remote_files) - set(local_files)) + if unexpected: + raise SystemExit(f"TestPyPI has unexpected files for this version: {unexpected}") + for filename, digest in local_files.items(): + if filename in remote_files and remote_files[filename] != digest: + raise SystemExit(f"TestPyPI integrity conflict for {filename}") PY - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: @@ -180,6 +194,41 @@ jobs: packages-dir: dist skip-existing: true print-hash: true + - name: Verify complete TestPyPI file set + env: + REGISTRY_JSON_BASE: https://test.pypi.org/pypi/guardscan-cli + RELEASE_VERSION: ${{ inputs.tag }} + run: | + python - <<'PY' + import hashlib, json, os, pathlib, time, urllib.error, urllib.request + + version = os.environ["RELEASE_VERSION"].removeprefix("v").replace("-rc.", "rc") + url = f'{os.environ["REGISTRY_JSON_BASE"]}/{version}/json' + local = { + wheel.name: hashlib.sha256(wheel.read_bytes()).hexdigest() + for wheel in pathlib.Path("dist").glob("*.whl") + } + for attempt in range(30): + try: + with urllib.request.urlopen(url, timeout=30) as response: + remote = { + item["filename"]: item["digests"]["sha256"] + for item in json.load(response)["urls"] + } + except urllib.error.HTTPError as error: + if error.code != 404: + raise + remote = {} + if remote == local: + break + if set(remote) - set(local): + raise SystemExit("TestPyPI returned unexpected files for this version") + if any(local.get(name) != digest for name, digest in remote.items()): + raise SystemExit("TestPyPI returned a conflicting wheel digest") + if attempt == 29: + raise SystemExit("TestPyPI did not converge to the complete tested wheel set") + time.sleep(10) + PY - name: Convert release tag to PEP 440 id: version shell: bash @@ -202,6 +251,7 @@ jobs: pypi: name: PyPI trusted publication + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 needs: [pypi-test] environment: pypi @@ -236,19 +286,61 @@ jobs: raise remote = {"urls": []} remote_files = {item["filename"]: item["digests"]["sha256"] for item in remote["urls"]} - for wheel in pathlib.Path("dist").glob("*.whl"): - digest = hashlib.sha256(wheel.read_bytes()).hexdigest() - if wheel.name in remote_files and remote_files[wheel.name] != digest: - raise SystemExit(f"PyPI integrity conflict for {wheel.name}") + local_files = { + wheel.name: hashlib.sha256(wheel.read_bytes()).hexdigest() + for wheel in pathlib.Path("dist").glob("*.whl") + } + unexpected = sorted(set(remote_files) - set(local_files)) + if unexpected: + raise SystemExit(f"PyPI has unexpected files for this version: {unexpected}") + for filename, digest in local_files.items(): + if filename in remote_files and remote_files[filename] != digest: + raise SystemExit(f"PyPI integrity conflict for {filename}") PY - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: packages-dir: dist skip-existing: true print-hash: true + - name: Verify complete PyPI file set + env: + REGISTRY_JSON_BASE: https://pypi.org/pypi/guardscan-cli + RELEASE_VERSION: ${{ inputs.tag }} + run: | + python - <<'PY' + import hashlib, json, os, pathlib, time, urllib.error, urllib.request - adapters: - name: Homebrew and Scoop bot PRs + version = os.environ["RELEASE_VERSION"].removeprefix("v").replace("-rc.", "rc") + url = f'{os.environ["REGISTRY_JSON_BASE"]}/{version}/json' + local = { + wheel.name: hashlib.sha256(wheel.read_bytes()).hexdigest() + for wheel in pathlib.Path("dist").glob("*.whl") + } + for attempt in range(30): + try: + with urllib.request.urlopen(url, timeout=30) as response: + remote = { + item["filename"]: item["digests"]["sha256"] + for item in json.load(response)["urls"] + } + except urllib.error.HTTPError as error: + if error.code != 404: + raise + remote = {} + if remote == local: + break + if set(remote) - set(local): + raise SystemExit("PyPI returned unexpected files for this version") + if any(local.get(name) != digest for name, digest in remote.items()): + raise SystemExit("PyPI returned a conflicting wheel digest") + if attempt == 29: + raise SystemExit("PyPI did not converge to the complete tested wheel set") + time.sleep(10) + PY + + catalog: + name: Shared Homebrew and Scoop catalog projection + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 needs: [github] permissions: @@ -261,7 +353,7 @@ jobs: app-id: ${{ vars.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} owner: ntanwir10 - repositories: homebrew-tap,scoop-bucket + repositories: homebrew-tap - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ inputs.ref }} @@ -274,55 +366,168 @@ jobs: node-version: ${{ env.RELEASE_NODE_VERSION }} - working-directory: cli run: npm ci - - working-directory: cli - run: npm run release:render -- --manifest ../payload/release-manifest.json --output-dir ../adapters - - name: Push deterministic adapter branches and open PRs + - name: Render the cryptographically bound shared catalog projection + working-directory: cli + env: + RELEASE_REF: ${{ inputs.ref }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + MANIFEST_SHA256="$(sha256sum ../payload/release-manifest.json | cut -d ' ' -f 1)" + node scripts/release/index.js catalog \ + --manifest ../payload/release-manifest.json \ + --output-dir ../catalog-output \ + --manifest-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/release-manifest.json" \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --generator-repository "$GITHUB_REPOSITORY" \ + --generator-commit "$RELEASE_REF" + - name: Validate complete generated catalog projection + run: | + test -f catalog-output/Formula/guardscan.rb + test -f catalog-output/bucket/guardscan.json + test -f catalog-output/channel-lock.json + test -f catalog/homebrew-tap/.github/workflows/verify.yml + node - <<'NODE' + const fs = require('fs'); + const crypto = require('crypto'); + const lock = JSON.parse(fs.readFileSync('catalog-output/channel-lock.json')); + if (lock.schemaVersion !== 'guardscan.channel-catalog.v1') { + throw new Error('unexpected channel catalog schema'); + } + const expected = new Map([ + ['Formula/guardscan.rb', 'catalog-output/Formula/guardscan.rb'], + ['bucket/guardscan.json', 'catalog-output/bucket/guardscan.json'], + ]); + for (const [target, source] of expected) { + const digest = crypto.createHash('sha256').update(fs.readFileSync(source)).digest('hex'); + if (lock.files?.[target]?.sha256 !== digest) { + throw new Error(`channel-lock digest mismatch for ${target}`); + } + } + NODE + - name: Push one deterministic catalog update env: GH_TOKEN: ${{ steps.app.outputs.token }} + RELEASE_CHANNEL: ${{ inputs.channel }} + RELEASE_TAG: ${{ inputs.tag }} run: | - BRANCH="guardscan/${{ inputs.tag }}" - for SPEC in "homebrew-tap:adapters/homebrew/Formula/guardscan.rb:Formula/guardscan.rb" \ - "scoop-bucket:adapters/scoop/bucket/guardscan.json:bucket/guardscan.json"; do - IFS=: read -r REPO SOURCE TARGET <<< "$SPEC" - gh repo clone "ntanwir10/$REPO" "$REPO" - ( - cd "$REPO" - git config user.name guardscan-release-bot - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then - git switch --detach "origin/$BRANCH" - if [ ! -f "$TARGET" ] || ! cmp --silent "../$SOURCE" "$TARGET"; then - echo "::error::Release-integrity incident: $REPO/$BRANCH differs from the rendered adapter" - exit 1 + CATALOG_REPOSITORY="ntanwir10/homebrew-tap" + if [ "$RELEASE_CHANNEL" = rc ]; then + BRANCH="channel-preview/$RELEASE_TAG" + else + BRANCH="guardscan/$RELEASE_TAG" + fi + gh repo clone "$CATALOG_REPOSITORY" catalog-checkout + ( + cd catalog-checkout + git config user.name guardscan-release-bot + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + projection_matches() { + for SPEC in \ + "../catalog-output/Formula/guardscan.rb:Formula/guardscan.rb" \ + "../catalog-output/bucket/guardscan.json:bucket/guardscan.json" \ + "../catalog-output/channel-lock.json:channel-lock.json" \ + "../catalog/homebrew-tap/.github/workflows/verify.yml:.github/workflows/verify.yml"; do + IFS=: read -r SOURCE TARGET <<< "$SPEC" + if [ ! -f "$TARGET" ] || ! cmp --silent "$SOURCE" "$TARGET"; then + return 1 fi - else - git switch -c "$BRANCH" - mkdir -p "$(dirname "$TARGET")" - cp "../$SOURCE" "$TARGET" - git add "$TARGET" - git commit -m "guardscan ${{ inputs.tag }}" - git push origin "$BRANCH" + done + } + MAIN_ALREADY_CURRENT=false + if [ "$RELEASE_CHANNEL" = stable ] && projection_matches; then + MAIN_ALREADY_CURRENT=true + BRANCH=main + elif [ "$RELEASE_CHANNEL" = stable ] && [ -f channel-lock.json ]; then + EXISTING_TAG="$(node -p 'require("./channel-lock.json").source?.tag || ""')" + if [ "$EXISTING_TAG" = "$RELEASE_TAG" ]; then + echo "::error::Release-integrity incident: main has this release identity with different catalog bytes" + exit 1 + fi + fi + if [ "$MAIN_ALREADY_CURRENT" = true ]; then + PR_NUMBER= + PR_STATE=MERGED + elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then + git switch --detach "origin/$BRANCH" + if ! projection_matches; then + echo "::error::Release-integrity incident: $CATALOG_REPOSITORY@$BRANCH differs" + exit 1 fi - PR_NUMBER="$(gh pr list --repo "ntanwir10/$REPO" \ + else + git switch -c "$BRANCH" + mkdir -p Formula bucket .github/workflows + cp ../catalog-output/Formula/guardscan.rb Formula/guardscan.rb + cp ../catalog-output/bucket/guardscan.json bucket/guardscan.json + cp ../catalog-output/channel-lock.json channel-lock.json + cp ../catalog/homebrew-tap/.github/workflows/verify.yml .github/workflows/verify.yml + git add Formula/guardscan.rb bucket/guardscan.json channel-lock.json .github/workflows/verify.yml + git commit -m "guardscan $RELEASE_TAG" + git push origin "$BRANCH" + fi + CATALOG_COMMIT="$(git rev-parse HEAD)" + if [ "$MAIN_ALREADY_CURRENT" = true ]; then + PR_NUMBER="$(gh api \ + "repos/$CATALOG_REPOSITORY/commits/$CATALOG_COMMIT/pulls" \ + --jq '.[0].number')" + if [ -z "$PR_NUMBER" ]; then + PR_NUMBER="$(gh pr list --repo "$CATALOG_REPOSITORY" \ + --search "\"GuardScan $RELEASE_TAG\" in:title" \ + --state merged --limit 1 --json number --jq '.[0].number')" + fi + if [ -z "$PR_NUMBER" ]; then + echo "::error::Current catalog bytes have no merged GuardScan projection PR" + exit 1 + fi + PR_STATE=MERGED + else + PR_NUMBER="$(gh pr list --repo "$CATALOG_REPOSITORY" \ --head "$BRANCH" --state all --limit 1 --json number --jq '.[0].number')" if [ -z "$PR_NUMBER" ]; then - gh pr create --repo "ntanwir10/$REPO" \ - --base main --head "$BRANCH" \ - --title "GuardScan ${{ inputs.tag }}" \ - --body "Generated from immutable release-manifest.json." - PR_NUMBER="$(gh pr list --repo "ntanwir10/$REPO" \ + PR_ARGS=( + --repo "$CATALOG_REPOSITORY" + --base main + --head "$BRANCH" + --title "GuardScan $RELEASE_TAG" + --body "Generated from GuardScan's immutable release manifest and bound by channel-lock.json." + ) + if [ "$RELEASE_CHANNEL" = rc ]; then PR_ARGS+=(--draft); fi + gh pr create "${PR_ARGS[@]}" + PR_NUMBER="$(gh pr list --repo "$CATALOG_REPOSITORY" \ --head "$BRANCH" --state all --limit 1 --json number --jq '.[0].number')" fi - PR_STATE="$(gh pr view --repo "ntanwir10/$REPO" "$PR_NUMBER" --json state --jq .state)" - if [ "${{ inputs.channel }}" = stable ] && [ "$PR_STATE" != MERGED ]; then - gh pr merge --repo "ntanwir10/$REPO" "$PR_NUMBER" --auto --squash + PR_STATE="$(gh pr view --repo "$CATALOG_REPOSITORY" "$PR_NUMBER" --json state --jq .state)" + if [ "$RELEASE_CHANNEL" = stable ] && [ "$PR_STATE" != MERGED ]; then + gh pr merge --repo "$CATALOG_REPOSITORY" "$PR_NUMBER" --auto --squash fi - ) - done + fi + export CATALOG_REPOSITORY BRANCH CATALOG_COMMIT PR_NUMBER PR_STATE + node - <<'NODE' + const fs = require('fs'); + const crypto = require('crypto'); + const lock = fs.readFileSync('channel-lock.json'); + fs.writeFileSync('../catalog-result.json', `${JSON.stringify({ + schemaVersion: 'guardscan.catalog-publication.v1', + repository: process.env.CATALOG_REPOSITORY, + branch: process.env.BRANCH, + commit: process.env.CATALOG_COMMIT, + pullRequest: process.env.PR_NUMBER ? Number(process.env.PR_NUMBER) : null, + state: process.env.PR_STATE, + lockSha256: crypto.createHash('sha256').update(lock).digest('hex'), + }, null, 2)}\n`); + NODE + ) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-catalog-${{ inputs.tag }} + path: | + catalog-output/channel-lock.json + catalog-result.json + if-no-files-found: error + retention-days: 30 winget: name: WinGet submission - if: inputs.channel == 'stable' + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' && inputs.channel == 'stable' runs-on: windows-2025 needs: [github] environment: winget @@ -366,7 +571,7 @@ jobs: chocolatey: name: Chocolatey submission - if: inputs.channel == 'stable' + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' && inputs.channel == 'stable' runs-on: windows-2025 needs: [github] environment: chocolatey diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml index ec18ca3..13d59c3 100644 --- a/.github/workflows/release-train.yml +++ b/.github/workflows/release-train.yml @@ -22,6 +22,8 @@ on: type: string schedule: - cron: "*/30 * * * *" + repository_dispatch: + types: [catalog_updated] permissions: contents: read @@ -36,7 +38,7 @@ env: jobs: scheduler: name: Dispatch reconciliation for every active train - if: github.event_name == 'schedule' + if: github.event_name == 'schedule' && vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 steps: - name: Create short-lived release app token @@ -66,9 +68,82 @@ jobs: } ' + catalog-hint: + name: Validate catalog dispatch hint and request reconciliation + if: github.event_name == 'repository_dispatch' && vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Create short-lived cross-repository release app token + id: app + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap + - name: Refetch and validate the hinted immutable catalog commit + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + HINT_VERSION: ${{ github.event.client_payload.version }} + HINT_CATALOG_COMMIT: ${{ github.event.client_payload.catalog_commit || github.event.client_payload.commit }} + HINT_LOCK_SHA256: ${{ github.event.client_payload.lock_sha256 || github.event.client_payload.lockSha256 }} + run: | + test -n "$HINT_CATALOG_COMMIT" + test -n "$HINT_LOCK_SHA256" + case "$HINT_CATALOG_COMMIT" in + *[!a-f0-9]*|'') echo "Invalid catalog commit hint" >&2; exit 1 ;; + esac + test "${#HINT_CATALOG_COMMIT}" = 40 + case "$HINT_LOCK_SHA256" in + *[!a-f0-9]*|'') echo "Invalid catalog lock digest hint" >&2; exit 1 ;; + esac + test "${#HINT_LOCK_SHA256}" = 64 + gh api \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/ntanwir10/homebrew-tap/contents/channel-lock.json?ref=$HINT_CATALOG_COMMIT" \ + > hinted-channel-lock.json + ACTUAL_LOCK_SHA256="$(sha256sum hinted-channel-lock.json | cut -d ' ' -f 1)" + test "$ACTUAL_LOCK_SHA256" = "$HINT_LOCK_SHA256" + RECONCILE_VERSION="$(node - <<'NODE' + const lock = require('./hinted-channel-lock.json'); + if ( + lock.schemaVersion !== 'guardscan.channel-catalog.v1' + || lock.source?.repository?.toLowerCase() !== process.env.GITHUB_REPOSITORY.toLowerCase() + || lock.source?.tag !== `v${lock.source?.version}` + || (process.env.HINT_VERSION && lock.source.version !== process.env.HINT_VERSION) + ) { + throw new Error('catalog dispatch hint does not match its refetched lock'); + } + process.stdout.write(lock.source.version); + NODE + )" + export RECONCILE_VERSION + gh api \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/${GITHUB_REPOSITORY}/contents/active-versions.json?ref=release-ledger" \ + > active-versions.json + RELEASE_PR="$(node - <<'NODE' + const active = require('./active-versions.json').trains || []; + const train = active.find(item => item.version === process.env.RECONCILE_VERSION); + process.stdout.write(train ? String(train.releasePr) : ''); + NODE + )" + if [ -z "$RELEASE_PR" ]; then + echo "Catalog hint is for an inactive release; no reconciliation requested." + exit 0 + fi + gh workflow run release-train.yml \ + --repo "$GITHUB_REPOSITORY" \ + -f action=reconcile \ + -f version="$RECONCILE_VERSION" \ + -f release_pr="$RELEASE_PR" + prepare: name: Resolve exact source and create protected tag - if: github.event_name == 'workflow_dispatch' && (inputs.action == 'candidate' || inputs.action == 'promote') + if: >- + github.event_name == 'workflow_dispatch' + && vars.RELEASE_AUTOMATION_ENABLED == 'true' + && (inputs.action == 'candidate' || inputs.action == 'promote') runs-on: ubuntu-24.04 concurrency: group: release-ledger @@ -93,6 +168,21 @@ jobs: GH_TOKEN: ${{ steps.app.outputs.token }} run: | test -n "${{ inputs.release_pr }}" + gh api \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/${GITHUB_REPOSITORY}/contents/active-versions.json?ref=release-ledger" \ + > active-versions.json + node - <<'NODE' + const active = require('./active-versions.json').trains || []; + const incompleteStable = active.filter(train => train.channel === 'stable'); + if (incompleteStable.length > 0) { + throw new Error( + `cannot start or promote while a stable train remains incomplete: ${ + incompleteStable.map(train => train.version).join(', ') + }` + ); + } + NODE HEAD_SHA="$(gh pr view "${{ inputs.release_pr }}" \ --repo "${GITHUB_REPOSITORY}" \ --json headRefOid \ @@ -257,6 +347,10 @@ jobs: name: Build exact tagged release if: needs.prepare.outputs.build == 'true' needs: [prepare] + permissions: + contents: read + id-token: write + attestations: write uses: ./.github/workflows/release-build.yml with: ref: ${{ needs.prepare.outputs.ref }} @@ -268,6 +362,10 @@ jobs: name: Publish every automated channel if: needs.prepare.outputs.build == 'true' needs: [prepare, build] + permissions: + actions: read + contents: write + id-token: write uses: ./.github/workflows/release-publish.yml with: ref: ${{ needs.prepare.outputs.ref }} @@ -299,6 +397,10 @@ jobs: with: name: release-payload-${{ needs.prepare.outputs.tag }} path: payload + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-catalog-${{ needs.prepare.outputs.tag }} + path: catalog-evidence - name: Fetch ledger, append chained events, and push env: GH_TOKEN: ${{ steps.app.outputs.token }} @@ -322,6 +424,11 @@ jobs: const manifest = fs.readFileSync('payload/release-manifest.json'); const manifestDocument = JSON.parse(manifest); const manifestSha256 = crypto.createHash('sha256').update(manifest).digest('hex'); + const catalogResult = JSON.parse(fs.readFileSync('catalog-evidence/catalog-result.json')); + const catalogLock = JSON.parse(fs.readFileSync('catalog-evidence/catalog-output/channel-lock.json')); + const lockSha256 = crypto.createHash('sha256') + .update(fs.readFileSync('catalog-evidence/catalog-output/channel-lock.json')) + .digest('hex'); const now = new Date().toISOString(); const timestampFor = idempotencyKey => ( readEvents(ledger).find(event => event.idempotencyKey === idempotencyKey)?.timestamp || now @@ -329,6 +436,12 @@ jobs: const base = {version, tag, commit}; if (readEvents(ledger).length === 0) { const idempotencyKey = `train:${tag}`; + const channels = [ + 'npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi', + ...(process.env.RELEASE_CHANNEL === 'stable' + ? ['homebrew-core', 'winget', 'chocolatey'] + : []), + ]; appendEvent(ledger, { ...base, timestamp: timestampFor(idempotencyKey), type: 'train_started', idempotencyKey, @@ -336,7 +449,7 @@ jobs: profile: 'full', releasePr: Number(process.env.RELEASE_PR), sourcePrHead: process.env.SOURCE_PR_HEAD, - channels: ['npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'winget', 'chocolatey', 'pypi'], + channels, }, }); } @@ -370,16 +483,32 @@ jobs: }); } for (const channel of ['homebrew', 'scoop']) { - const idempotencyKey = `adapter:${channel}:${tag}`; + const catalogPath = channel === 'homebrew' + ? 'Formula/guardscan.rb' + : 'bucket/guardscan.json'; + const pathDigest = catalogLock.files?.[catalogPath]?.sha256; + if (!/^[a-f0-9]{64}$/.test(pathDigest || '')) { + throw new Error(`catalog lock has no valid digest for ${catalogPath}`); + } + const idempotencyKey = `catalog-submitted:${channel}:${tag}:${lockSha256}`; appendEvent(ledger, { ...base, timestamp: timestampFor(idempotencyKey), - type: process.env.RELEASE_CHANNEL === 'stable' ? 'channel_published' : 'channel_submitted', + type: 'channel_submitted', channel, idempotencyKey, payload: { artifactIds: artifactIds[channel], - remoteIdentity: `ntanwir10/${channel}:${tag}`, - remoteDigest: manifestSha256, + remoteIdentity: `github:${catalogResult.repository}@${catalogResult.commit}#${catalogPath}`, + remoteDigest: pathDigest, + catalog: { + repository: catalogResult.repository, + commit: catalogResult.commit, + pullRequest: catalogResult.pullRequest, + lockDigest: lockSha256, + manifestDigest: manifestSha256, + path: catalogPath, + fileDigest: pathDigest, + }, }, }); } @@ -408,8 +537,11 @@ jobs: active.trains = active.trains.filter(train => train.version !== process.env.RELEASE_TAG.slice(1)); if (process.env.RELEASE_CHANNEL === 'stable') { active.trains = active.trains.filter(train => !( - train.channel === 'rc' - && train.releasePr === Number(process.env.RELEASE_PR) + train.channel === 'stable' + || ( + train.channel === 'rc' + && train.releasePr === Number(process.env.RELEASE_PR) + ) )); } active.trains.push({ @@ -433,17 +565,23 @@ jobs: name: Reconcile remote state and trigger eligible promotion if: github.event_name == 'workflow_dispatch' && inputs.action == 'reconcile' runs-on: ubuntu-24.04 + concurrency: + group: release-ledger + cancel-in-progress: false steps: - - name: Create short-lived release app token + - name: Create short-lived cross-repository release app token id: app uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ vars.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: v${{ inputs.version }} fetch-depth: 0 + token: ${{ steps.app.outputs.token }} - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.RELEASE_NODE_VERSION }} @@ -459,8 +597,217 @@ jobs: --ledger release-events.jsonl \ --tag "v${{ inputs.version }}" > reconciliation.json cat reconciliation.json + - name: Authoritatively refetch and reconcile the shared catalog + id: catalog + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + RELEASE_VERSION: ${{ inputs.version }} + AUTOMATION_ENABLED: ${{ vars.RELEASE_AUTOMATION_ENABLED }} + run: | + ACTIVE_CHANNEL="$(gh api \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/${GITHUB_REPOSITORY}/contents/active-versions.json?ref=release-ledger" \ + --jq ".trains[] | select(.version == \"$RELEASE_VERSION\") | .channel")" + test "$ACTIVE_CHANNEL" = rc || test "$ACTIVE_CHANNEL" = stable + if [ "$ACTIVE_CHANNEL" = rc ]; then + CATALOG_REF="channel-preview/v$RELEASE_VERSION" + UPDATE_BRANCH="$CATALOG_REF" + else + CATALOG_REF=main + UPDATE_BRANCH="guardscan/v$RELEASE_VERSION" + fi + gh repo clone ntanwir10/homebrew-tap catalog-checkout + if git -C catalog-checkout fetch origin "$CATALOG_REF"; then + git -C catalog-checkout switch --detach "origin/$CATALOG_REF" + fi + CATALOG_COMMIT="$(git -C catalog-checkout rev-parse HEAD)" + mkdir catalog-verification + gh release download "v$RELEASE_VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --dir catalog-verification \ + --pattern release-manifest.json + MANIFEST_SHA256="$(sha256sum catalog-verification/release-manifest.json | cut -d ' ' -f 1)" + LEDGER_MANIFEST_SHA256="$(node - <<'NODE' + const {readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + process.stdout.write(materializeReleaseState(readEvents('release-events.jsonl')).manifestSha256 || ''); + NODE + )" + test "$MANIFEST_SHA256" = "$LEDGER_MANIFEST_SHA256" + SOURCE_COMMIT="$(git rev-parse HEAD)" + node cli/scripts/release/index.js catalog-status \ + --manifest catalog-verification/release-manifest.json \ + --catalog-root catalog-checkout \ + --manifest-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v$RELEASE_VERSION/release-manifest.json" \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --generator-repository "$GITHUB_REPOSITORY" \ + --generator-commit "$SOURCE_COMMIT" \ + > catalog-verification/status.json + cat catalog-verification/status.json + INTEGRITY_INCIDENT="$(node -p 'String(require("./catalog-verification/status.json").integrityIncident)')" + CATALOG_CLASSIFICATION="$(node -p 'require("./catalog-verification/status.json").classification')" + if [ "$INTEGRITY_INCIDENT" = true ]; then + echo "::error::Release-integrity incident: catalog classification is $CATALOG_CLASSIFICATION" + exit 1 + fi + if [ "$CATALOG_CLASSIFICATION" != exact ]; then + if [ "$AUTOMATION_ENABLED" != true ]; then + echo "exact=false" >> "$GITHUB_OUTPUT" + echo "Automation is disabled; catalog drift was diagnosed without mutation." + exit 0 + fi + if git -C catalog-checkout show-ref --verify --quiet "refs/remotes/origin/$UPDATE_BRANCH"; then + git -C catalog-checkout switch --detach "origin/$UPDATE_BRANCH" + node cli/scripts/release/index.js catalog \ + --manifest catalog-verification/release-manifest.json \ + --output-dir catalog-checkout \ + --manifest-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v$RELEASE_VERSION/release-manifest.json" \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --generator-repository "$GITHUB_REPOSITORY" \ + --generator-commit "$SOURCE_COMMIT" \ + --check + cmp catalog/homebrew-tap/.github/workflows/verify.yml \ + catalog-checkout/.github/workflows/verify.yml + else + git -C catalog-checkout switch -c "$UPDATE_BRANCH" origin/main + node cli/scripts/release/index.js catalog \ + --manifest catalog-verification/release-manifest.json \ + --output-dir catalog-checkout \ + --manifest-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v$RELEASE_VERSION/release-manifest.json" \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --generator-repository "$GITHUB_REPOSITORY" \ + --generator-commit "$SOURCE_COMMIT" + mkdir -p catalog-checkout/.github/workflows + cp catalog/homebrew-tap/.github/workflows/verify.yml \ + catalog-checkout/.github/workflows/verify.yml + git -C catalog-checkout config user.name guardscan-release-bot + git -C catalog-checkout config user.email 41898282+github-actions[bot]@users.noreply.github.com + git -C catalog-checkout add Formula/guardscan.rb bucket/guardscan.json \ + channel-lock.json .github/workflows/verify.yml + git -C catalog-checkout commit -m "guardscan v$RELEASE_VERSION" + git -C catalog-checkout push origin "$UPDATE_BRANCH" + fi + PR_NUMBER="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$UPDATE_BRANCH" --state all --limit 1 --json number --jq '.[0].number')" + if [ -z "$PR_NUMBER" ]; then + PR_ARGS=( + --repo ntanwir10/homebrew-tap + --base main + --head "$UPDATE_BRANCH" + --title "GuardScan v$RELEASE_VERSION" + --body "Generated from GuardScan's immutable release manifest and bound by channel-lock.json." + ) + if [ "$ACTIVE_CHANNEL" = rc ]; then PR_ARGS+=(--draft); fi + gh pr create "${PR_ARGS[@]}" + PR_NUMBER="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$UPDATE_BRANCH" --state all --limit 1 --json number --jq '.[0].number')" + fi + if [ "$ACTIVE_CHANNEL" = stable ]; then + PR_STATE="$(gh pr view --repo ntanwir10/homebrew-tap "$PR_NUMBER" --json state --jq .state)" + if [ "$PR_STATE" != MERGED ]; then + gh pr merge --repo ntanwir10/homebrew-tap "$PR_NUMBER" --auto --squash + fi + fi + echo "exact=false" >> "$GITHUB_OUTPUT" + echo "Catalog is converging through PR #$PR_NUMBER; verification remains pending." + exit 0 + fi + if ! cmp --silent catalog/homebrew-tap/.github/workflows/verify.yml \ + catalog-checkout/.github/workflows/verify.yml; then + echo "::error::Release-integrity incident: catalog verification workflow differs from its source" + exit 1 + fi + CATALOG_PULL_REQUEST="$(node - <<'NODE' + const {readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + const state = materializeReleaseState(readEvents('release-events.jsonl')); + process.stdout.write(String(state.channels?.homebrew?.catalog?.pullRequest || '')); + NODE + )" + if [ -z "$CATALOG_PULL_REQUEST" ]; then + CATALOG_PULL_REQUEST="$(gh api \ + "repos/ntanwir10/homebrew-tap/commits/$CATALOG_COMMIT/pulls" \ + --jq '.[0].number')" + fi + case "$CATALOG_PULL_REQUEST" in + ''|*[!0-9]*) echo "Catalog publication evidence has no pull request" >&2; exit 1 ;; + esac + export ACTIVE_CHANNEL CATALOG_COMMIT CATALOG_PULL_REQUEST CATALOG_REF MANIFEST_SHA256 SOURCE_COMMIT + node - <<'NODE' + const fs = require('fs'); + const crypto = require('crypto'); + const lockFile = fs.readFileSync('catalog-checkout/channel-lock.json'); + const lock = JSON.parse(lockFile); + fs.writeFileSync('catalog-verification/evidence.json', `${JSON.stringify({ + repository: 'ntanwir10/homebrew-tap', + ref: process.env.CATALOG_REF, + commit: process.env.CATALOG_COMMIT, + pullRequest: Number(process.env.CATALOG_PULL_REQUEST), + channel: process.env.ACTIVE_CHANNEL, + manifestSha256: process.env.MANIFEST_SHA256, + lockSha256: crypto.createHash('sha256').update(lockFile).digest('hex'), + files: lock.files, + }, null, 2)}\n`); + NODE + echo "exact=true" >> "$GITHUB_OUTPUT" + - name: Persist refetched catalog publication evidence + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' && steps.catalog.outputs.exact == 'true' + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + node - <<'NODE' + const fs = require('fs'); + const {appendEvent, readEvents} = require('./cli/scripts/release/events'); + const evidence = require('./catalog-verification/evidence.json'); + const tag = `v${process.env.RELEASE_VERSION}`; + const events = readEvents('release-events.jsonl'); + if (events.length === 0) throw new Error(`release ledger is empty for ${tag}`); + const commit = events[0].commit; + const timestamp = new Date().toISOString(); + for (const [channel, catalogPath] of [ + ['homebrew', 'Formula/guardscan.rb'], + ['scoop', 'bucket/guardscan.json'], + ]) { + const remoteDigest = evidence.files?.[catalogPath]?.sha256; + if (!/^[a-f0-9]{64}$/.test(remoteDigest || '')) { + throw new Error(`catalog evidence has no valid digest for ${catalogPath}`); + } + const idempotencyKey = `catalog-published:${channel}:${tag}:${evidence.commit}:${evidence.lockSha256}`; + const existing = events.find(event => event.idempotencyKey === idempotencyKey); + appendEvent('release-events.jsonl', { + version: process.env.RELEASE_VERSION, + tag, + commit, + timestamp: existing?.timestamp || timestamp, + type: 'channel_published', + channel, + idempotencyKey, + payload: { + remoteIdentity: `github:${evidence.repository}@${evidence.commit}#${catalogPath}`, + remoteDigest, + catalog: { + repository: evidence.repository, + commit: evidence.commit, + pullRequest: evidence.pullRequest, + lockDigest: evidence.lockSha256, + manifestDigest: evidence.manifestSha256, + path: catalogPath, + fileDigest: remoteDigest, + }, + }, + }); + } + NODE + git worktree add ledger-branch origin/release-ledger + cp release-events.jsonl "ledger-branch/events/v${{ inputs.version }}.jsonl" + ( + cd ledger-branch + git config user.name guardscan-release-bot + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add "events/v${{ inputs.version }}.jsonl" + git commit -m "catalog reconciled: v${{ inputs.version }}" || exit 0 + git push origin HEAD:release-ledger + ) - name: Trigger promotion when the machine policy is eligible - if: contains(inputs.version, '-rc.') + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' && contains(inputs.version, '-rc.') env: GH_TOKEN: ${{ steps.app.outputs.token }} run: | @@ -486,7 +833,10 @@ jobs: rollback: name: Append rollback and prepare forward fix - if: github.event_name == 'workflow_dispatch' && inputs.action == 'rollback' + if: >- + github.event_name == 'workflow_dispatch' + && vars.RELEASE_AUTOMATION_ENABLED == 'true' + && inputs.action == 'rollback' runs-on: ubuntu-24.04 concurrency: group: release-ledger diff --git a/catalog/homebrew-tap/.github/workflows/verify.yml b/catalog/homebrew-tap/.github/workflows/verify.yml new file mode 100644 index 0000000..d9a17a6 --- /dev/null +++ b/catalog/homebrew-tap/.github/workflows/verify.yml @@ -0,0 +1,271 @@ +name: Verify GuardScan channel catalog + +on: + pull_request: + paths: + - Formula/** + - bucket/** + - channel-lock.json + - .github/workflows/verify.yml + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: channel-catalog-${{ github.ref }} + cancel-in-progress: true + +env: + RELEASE_NODE_VERSION: 22.23.1 + SCOOP_INSTALL_COMMIT: b0ee913725139b816f9178163af0aecdba07a7ed + SCOOP_COMMIT: b588a06e41d920d2123ec70aee682bae14935939 + +jobs: + integrity: + name: Source and lock integrity + runs-on: ubuntu-24.04 + outputs: + source_commit: ${{ steps.lock.outputs.source_commit }} + manifest_url: ${{ steps.lock.outputs.manifest_url }} + manifest_sha256: ${{ steps.lock.outputs.manifest_sha256 }} + generator_repository: ${{ steps.lock.outputs.generator_repository }} + generator_commit: ${{ steps.lock.outputs.generator_commit }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Validate the untrusted lock before using it + id: lock + shell: bash + run: | + node <<'NODE' + const fs = require('fs'); + const lock = JSON.parse(fs.readFileSync('channel-lock.json', 'utf8')); + const fail = message => { throw new Error(message); }; + const sha = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); + const commit = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value); + if (lock.schemaVersion !== 'guardscan.channel-catalog.v1') fail('unsupported channel lock'); + if (lock.source?.repository !== 'ntanwir10/GuardScan') fail('unexpected source repository'); + if (lock.generator?.repository !== 'ntanwir10/GuardScan') fail('unexpected generator repository'); + if (!commit(lock.source?.commit) || !commit(lock.generator?.commit)) fail('invalid commit identity'); + if (lock.source.commit !== lock.generator.commit) fail('generator must come from the release source commit'); + if (lock.source?.tag !== `v${lock.source?.version}`) fail('tag and version do not match'); + const expectedUrl = + `https://github.com/ntanwir10/GuardScan/releases/download/${lock.source.tag}/release-manifest.json`; + if (lock.source?.manifestUrl !== expectedUrl) fail('manifest URL is not canonical'); + if (!sha(lock.source?.manifestSha256)) fail('invalid manifest digest'); + const expectedFiles = ['Formula/guardscan.rb', 'bucket/guardscan.json']; + if (JSON.stringify(Object.keys(lock.files || {}).sort()) !== JSON.stringify(expectedFiles)) { + fail('channel lock has unexpected generated files'); + } + for (const filename of expectedFiles) { + if (!sha(lock.files[filename]?.sha256)) fail(`invalid digest for ${filename}`); + } + const output = [ + `source_commit=${lock.source.commit}`, + `manifest_url=${lock.source.manifestUrl}`, + `manifest_sha256=${lock.source.manifestSha256}`, + `generator_repository=${lock.generator.repository}`, + `generator_commit=${lock.generator.commit}`, + ].join('\n'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${output}\n`); + NODE + + - name: Download and authenticate the immutable release manifest + env: + MANIFEST_URL: ${{ steps.lock.outputs.manifest_url }} + MANIFEST_SHA256: ${{ steps.lock.outputs.manifest_sha256 }} + SOURCE_COMMIT: ${{ steps.lock.outputs.source_commit }} + shell: bash + run: | + curl --fail --location --proto '=https' --tlsv1.2 \ + --output release-manifest.json "$MANIFEST_URL" + node <<'NODE' + const crypto = require('crypto'); + const fs = require('fs'); + const manifestBytes = fs.readFileSync('release-manifest.json'); + const actual = crypto.createHash('sha256').update(manifestBytes).digest('hex'); + if (actual !== process.env.MANIFEST_SHA256) throw new Error('release manifest digest mismatch'); + const manifest = JSON.parse(manifestBytes); + const lock = JSON.parse(fs.readFileSync('channel-lock.json', 'utf8')); + if (manifest.version !== lock.source.version + || manifest.tag !== lock.source.tag + || manifest.commit !== process.env.SOURCE_COMMIT) { + throw new Error('release manifest source identity mismatch'); + } + NODE + + - name: Check out the exact GuardScan generator + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: ntanwir10/GuardScan + ref: ${{ steps.lock.outputs.source_commit }} + path: .guardscan-source + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.RELEASE_NODE_VERSION }} + cache: npm + cache-dependency-path: .guardscan-source/cli/package-lock.json + + - name: Reproduce every generated byte + working-directory: .guardscan-source/cli + env: + CATALOG_ROOT: ${{ github.workspace }} + GENERATOR_REPOSITORY: ${{ steps.lock.outputs.generator_repository }} + GENERATOR_COMMIT: ${{ steps.lock.outputs.generator_commit }} + MANIFEST_URL: ${{ steps.lock.outputs.manifest_url }} + MANIFEST_SHA256: ${{ steps.lock.outputs.manifest_sha256 }} + shell: bash + run: | + npm ci + node scripts/release/index.js catalog \ + --manifest "$CATALOG_ROOT/release-manifest.json" \ + --output-dir "$CATALOG_ROOT" \ + --manifest-url "$MANIFEST_URL" \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --generator-repository "$GENERATOR_REPOSITORY" \ + --generator-commit "$GENERATOR_COMMIT" \ + --check + cmp "$CATALOG_ROOT/.github/workflows/verify.yml" \ + "$CATALOG_ROOT/.guardscan-source/catalog/homebrew-tap/.github/workflows/verify.yml" + + homebrew: + name: Homebrew lifecycle + needs: integrity + runs-on: macos-15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Style, audit, install, invoke, test, and uninstall + shell: bash + run: | + brew style Formula/guardscan.rb + brew audit --strict Formula/guardscan.rb + brew install --formula "$GITHUB_WORKSPACE/Formula/guardscan.rb" + guardscan --version + brew test guardscan + brew uninstall guardscan + + scoop: + name: Scoop lifecycle + needs: integrity + runs-on: windows-2025 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Check out the pinned Scoop installer + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: ScoopInstaller/Install + ref: ${{ env.SCOOP_INSTALL_COMMIT }} + path: .scoop-installer + persist-credentials: false + - name: Install the pinned Scoop CLI + shell: pwsh + run: | + $scoopRoot = Join-Path $env:RUNNER_TEMP 'scoop' + & .\.scoop-installer\install.ps1 -RunAsAdmin -ScoopDir $scoopRoot + if (-not $?) { throw 'Pinned Scoop installer failed' } + git -C "$scoopRoot\apps\scoop\current" fetch origin $env:SCOOP_COMMIT --depth 1 + if ($LASTEXITCODE -ne 0) { throw 'Unable to fetch the pinned Scoop CLI' } + git -C "$scoopRoot\apps\scoop\current" checkout --detach $env:SCOOP_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Unable to select the pinned Scoop CLI' } + "$scoopRoot\shims" | Out-File $env:GITHUB_PATH -Append + - name: Validate, install, invoke, and remove the portable archive + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $manifest = Get-Content -Raw -LiteralPath bucket/guardscan.json | ConvertFrom-Json + if ($manifest.version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') { + throw 'Invalid Scoop version' + } + $download = Join-Path $env:RUNNER_TEMP 'guardscan.zip' + Invoke-WebRequest -Uri $manifest.architecture.'64bit'.url -OutFile $download + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $download).Hash.ToLowerInvariant() + if ($actual -ne $manifest.architecture.'64bit'.hash) { + throw 'Scoop archive digest mismatch' + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($download) + try { + foreach ($entry in $archive.Entries) { + $name = $entry.FullName.Replace('\', '/') + if ($name.StartsWith('/') -or $name -match '(^|/)\.\.(/|$)' -or $name.Contains(':')) { + throw "Unsafe archive entry: $name" + } + } + } finally { + $archive.Dispose() + } + $install = Join-Path $env:RUNNER_TEMP 'guardscan-scoop-install' + Expand-Archive -LiteralPath $download -DestinationPath $install + $executables = @(Get-ChildItem -LiteralPath $install -Recurse -Filter guardscan.exe) + if ($executables.Count -ne 1) { throw 'Expected one guardscan.exe' } + $reported = & $executables[0].FullName --version + if ($LASTEXITCODE -ne 0 -or $reported -notmatch [regex]::Escape($manifest.version)) { + throw 'Installed Scoop executable failed its version check' + } + Remove-Item -LiteralPath $install -Recurse -Force + - name: Install, update-check, invoke, and uninstall through Scoop + shell: pwsh + run: | + scoop bucket add guardscan-catalog $env:GITHUB_WORKSPACE + if ($LASTEXITCODE -ne 0) { throw 'Unable to add the local GuardScan bucket' } + scoop install guardscan-catalog/guardscan + if ($LASTEXITCODE -ne 0) { throw 'Scoop installation failed' } + guardscan --version + if ($LASTEXITCODE -ne 0) { throw 'Scoop invocation failed' } + scoop update guardscan + if ($LASTEXITCODE -ne 0) { throw 'Scoop update check failed' } + scoop uninstall guardscan + if ($LASTEXITCODE -ne 0) { throw 'Scoop uninstall failed' } + scoop bucket rm guardscan-catalog + if ($LASTEXITCODE -ne 0) { throw 'Unable to remove the local GuardScan bucket' } + + notify: + name: Notify authoritative release ledger + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [integrity, homebrew, scoop] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Create a short-lived cross-repository app token + id: app + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap + - name: Send a non-authoritative catalog update hint + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + CATALOG_COMMIT: ${{ github.sha }} + shell: bash + run: | + node <<'NODE' > dispatch.json + const crypto = require('crypto'); + const fs = require('fs'); + const lock = fs.readFileSync('channel-lock.json'); + const lockDocument = JSON.parse(lock); + process.stdout.write(JSON.stringify({ + event_type: 'catalog_updated', + client_payload: { + repository: process.env.GITHUB_REPOSITORY, + version: lockDocument.source.version, + catalog_commit: process.env.CATALOG_COMMIT, + lock_sha256: crypto.createHash('sha256').update(lock).digest('hex'), + }, + })); + NODE + gh api --method POST repos/ntanwir10/GuardScan/dispatches --input dispatch.json diff --git a/catalog/homebrew-tap/README.md b/catalog/homebrew-tap/README.md new file mode 100644 index 0000000..cb84d5c --- /dev/null +++ b/catalog/homebrew-tap/README.md @@ -0,0 +1,19 @@ +# GuardScan channel catalog + +This directory is the canonical bootstrap scaffold for +[`ntanwir10/homebrew-tap`](https://github.com/ntanwir10/homebrew-tap). +The public catalog repository contains both supported first-party package-manager +adapters: + +- `Formula/guardscan.rb` for Homebrew +- `bucket/guardscan.json` for Scoop +- `channel-lock.json`, which binds both generated files to one immutable + GuardScan release manifest and source commit + +GuardScan release automation owns catalog contents. Do not edit generated +formulae, manifests, or the lock by hand. Catalog pull requests must reproduce +byte-for-byte from the exact GuardScan commit named in `channel-lock.json`. + +The catalog is a generated projection, not a source mirror or a second release +authority. Its verification workflow is also sourced from this scaffold so that +catalog policy changes are reviewed with the release generator. diff --git a/cli/__tests__/scripts/release-please-config.test.ts b/cli/__tests__/scripts/release-please-config.test.ts index 9b4166a..1997bbd 100644 --- a/cli/__tests__/scripts/release-please-config.test.ts +++ b/cli/__tests__/scripts/release-please-config.test.ts @@ -46,6 +46,7 @@ describe('release pull request automation', () => { expect(source).toContain('googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071'); expect(source).toContain('secrets.RELEASE_APP_PRIVATE_KEY'); expect(source).toContain('skip-github-release: true'); + expect(source).toContain("vars.RELEASE_AUTOMATION_ENABLED == 'true'"); expect(source).not.toContain('secrets.GITHUB_TOKEN'); expect(source).not.toContain('github.token'); }); diff --git a/cli/__tests__/scripts/release-workflows.test.ts b/cli/__tests__/scripts/release-workflows.test.ts index 04f779c..88f5629 100644 --- a/cli/__tests__/scripts/release-workflows.test.ts +++ b/cli/__tests__/scripts/release-workflows.test.ts @@ -4,6 +4,10 @@ import yaml from 'js-yaml'; const repositoryRoot = path.resolve(__dirname, '../../..'); const workflowRoot = path.join(repositoryRoot, '.github/workflows'); +const catalogWorkflow = path.join( + repositoryRoot, + 'catalog/homebrew-tap/.github/workflows/verify.yml' +); const releaseWorkflows = [ 'release-build.yml', 'release-canary.yml', @@ -52,9 +56,48 @@ describe('zero-touch release workflow contracts', () => { } }); + it('keeps workflow heredoc terminators at shell column zero', () => { + const sources = [ + ...['ci.yml', ...releaseWorkflows].map(workflowSource), + fs.readFileSync(catalogWorkflow, 'utf8'), + ]; + for (const source of sources) { + const workflow = yaml.load(source) as { + jobs?: Record}>; + }; + for (const job of Object.values(workflow.jobs || {})) { + for (const step of job.steps || []) { + if (!step.run) continue; + const lines = step.run.split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const opener = lines[index].match(/<<'?([A-Z][A-Z0-9_]*)'?/); + if (!opener) continue; + const terminatorIndex = lines.findIndex( + (line, candidate) => candidate > index && line.trim() === opener[1] + ); + const terminator = lines[terminatorIndex]; + expect(terminator).toBe(opener[1]); + if (/\bnode(?:\s+-)?\s+< new Function(body)).not.toThrow(); + } + } + } + } + } + }); + it('runs one concurrency-safe RC soak and machine-only promotion train', () => { const train = workflowSource('release-train.yml'); const canary = workflowSource('release-canary.yml'); + const ledgerSeed = JSON.parse(fs.readFileSync( + path.join(repositoryRoot, '.github/release-ledger/active-versions.json'), + 'utf8' + )); + expect(ledgerSeed).toEqual({ + schemaVersion: 'guardscan.active-trains.v1', + trains: [], + }); expect(train).toContain('cron: "*/30 * * * *"'); expect(train).toContain("group: release-train-${{ inputs.version || 'scheduler' }}"); expect(train).toContain('actions/create-github-app-token@'); @@ -66,6 +109,22 @@ describe('zero-touch release workflow contracts', () => { expect(canary).toContain('cron: "7 * * * *"'); expect(canary).toContain('group: release-ledger'); expect(train).toContain('samples.length >= 24'); + expect(train).toContain("types: [catalog_updated]"); + expect(train).toContain('hinted-channel-lock.json'); + expect(train).toContain('channel-preview/v$RELEASE_VERSION'); + expect(train).toContain("type: 'channel_published'"); + expect(train).toContain("type: 'channel_submitted'"); + expect(train).toContain("['homebrew-core', 'winget', 'chocolatey']"); + expect(train).toContain('cannot start or promote while a stable train remains incomplete'); + expect(canary).toContain("train.channel !== 'stable'"); + expect(canary).toContain('reconcileRelease(materializeReleaseState(readEvents(ledger))).complete'); + expect(train).toContain("vars.RELEASE_AUTOMATION_ENABLED == 'true'"); + expect(train).toMatch( + /build:\n[\s\S]*?permissions:\n\s+contents: read\n\s+id-token: write\n\s+attestations: write/ + ); + expect(train).toMatch( + /publish:\n[\s\S]*?permissions:\n\s+actions: read\n\s+contents: write\n\s+id-token: write/ + ); }); it('builds signed artifacts and publishes through isolated provider environments', () => { @@ -91,9 +150,55 @@ describe('zero-touch release workflow contracts', () => { expect(build).toContain('actions/attest-build-provenance@'); expect(build).toContain('release-manifest.json'); expect(publish).toContain('--provenance'); + expect(publish).toContain('RELEASE_NPM_VERSION: 11.5.2'); + expect(publish).toContain('npm install --global "npm@${RELEASE_NPM_VERSION}"'); expect(publish).toContain('pypa/gh-action-pypi-publish@'); + expect(publish).toContain('cp payload/*.whl dist/'); + expect(publish).toContain('Verify complete TestPyPI file set'); + expect(publish).toContain('Verify complete PyPI file set'); + expect(publish).toContain('remote == local'); + expect(publish).not.toContain('try:\n try:'); expect(publish).toContain('wingetcreate submit'); expect(publish).toContain('choco push'); + expect(combined).toContain('/.github/workflows/release-train.yml@'); + expect(combined).not.toContain('/.github/workflows/release-build.yml@'); + }); + + it('uses one cryptographically bound shared Homebrew and Scoop catalog', () => { + const publish = workflowSource('release-publish.yml'); + const canary = workflowSource('release-canary.yml'); + const train = workflowSource('release-train.yml'); + const combined = `${publish}\n${canary}\n${train}`; + expect(combined).toContain('ntanwir10/homebrew-tap'); + expect(combined).not.toContain('scoop-bucket'); + expect(publish).toContain('Formula/guardscan.rb'); + expect(publish).toContain('bucket/guardscan.json'); + expect(publish).toContain('channel-lock.json'); + expect(publish).toContain('catalog/homebrew-tap/.github/workflows/verify.yml'); + expect(publish).toContain('channel-preview/$RELEASE_TAG'); + expect(publish).toContain('--generator-commit "$RELEASE_REF"'); + expect(train).toContain('github:${catalogResult.repository}@${catalogResult.commit}#${catalogPath}'); + expect(train).toContain('catalog: {'); + expect(train).toContain("type: 'channel_submitted'"); + }); + + it('ships a pinned, reproducible, native catalog verification workflow', () => { + const source = fs.readFileSync(catalogWorkflow, 'utf8'); + expect(yaml.load(source)).toBeTruthy(); + for (const match of source.matchAll(/^\s*uses:\s+([^./\s][^@\s]+)@([^\s#]+)/gm)) { + expect(match[2]).toMatch(/^[a-f0-9]{40}$/); + } + expect(source).toContain('manifest digest mismatch'); + expect(source).toContain('node scripts/release/index.js catalog'); + expect(source).toContain('--check'); + expect(source).toContain('runs-on: macos-15'); + expect(source).toContain('runs-on: windows-2025'); + expect(source).toContain('brew install --formula'); + expect(source).toContain('SCOOP_INSTALL_COMMIT: b0ee913725139b816f9178163af0aecdba07a7ed'); + expect(source).toContain('SCOOP_COMMIT: b588a06e41d920d2123ec70aee682bae14935939'); + expect(source).toContain('scoop install guardscan-catalog/guardscan'); + expect(source).toContain('guardscan.exe'); + expect(source).toContain("event_type: 'catalog_updated'"); }); it('exposes every required maintainer release interface', () => { From 7a248393a7280065c19e3f593c4421846bb12b92 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sat, 25 Jul 2026 23:41:49 -0400 Subject: [PATCH 04/23] docs(release): define zero-touch catalog operations --- docs/RELEASE_AUTOMATION.md | 106 +++++++++++++++++++++++++++++++++++-- docs/RELEASE_ONBOARDING.md | 82 +++++++++++++++++++++++++--- tasks/plan.md | 92 +++++++++++++++++++++++++------- tasks/todo.md | 12 ++++- 4 files changed, 259 insertions(+), 33 deletions(-) diff --git a/docs/RELEASE_AUTOMATION.md b/docs/RELEASE_AUTOMATION.md index f926256..4eab0e2 100644 --- a/docs/RELEASE_AUTOMATION.md +++ b/docs/RELEASE_AUTOMATION.md @@ -23,11 +23,93 @@ The automation is fail-closed. A tag is an identity created by the release train | `.github/workflows/release-please.yml` | Maintains the stable release PR only, using a short-lived GitHub App token. | | `.github/workflows/release-train.yml` | Derives RC commits, creates protected tags, dispatches builds/publication, reconciles every 30 minutes, promotes, rolls back, and persists release events. | | `.github/workflows/release-build.yml` | Builds the exact npm tarball and five SEA targets, signs, notarizes, generates SPDX/CycloneDX, creates wheels, attests, archives deterministically, and aggregates the manifest/checksums. | -| `.github/workflows/release-publish.yml` | Publishes the tested handoffs through OIDC or first-party bot repositories. | +| `.github/workflows/release-publish.yml` | Publishes tested registry handoffs through OIDC and opens the generated shared-catalog update PR. | | `.github/workflows/release-canary.yml` | Runs hourly public install/invoke/uninstall canaries and polls moderated registries. | Release workflows deliberately do not use dependency caches. Release-critical actions are pinned to immutable commits. +## Shared package-manager catalog + +GuardScan is the sole release authority. The public +`ntanwir10/homebrew-tap` repository is a generated, cryptographically bound +projection of one GuardScan release manifest; it is not a second source of +product or release state. The shared catalog contains both first-party +package-manager adapters: + +```text +Formula/guardscan.rb +bucket/guardscan.json +channel-lock.json +.github/workflows/verify.yml +``` + +A stable release renders the formula, Scoop manifest, and lock together and +opens one catalog pull request. RCs use the temporary branch +`channel-preview/vVERSION`; stable users read catalog `main`. Catalog CI +fetches the immutable GuardScan manifest, verifies its SHA-256 and tagged +source commit, reruns the renderer from that exact source commit, checks every +asset URL and digest, and runs the native lifecycle tests before merge. +Hand-written catalog changes fail unless they are byte-identical to renderer +output. + +`channel-lock.json` uses schema `guardscan.channel-catalog.v1` and binds the +projection without trying to include the catalog commit in its own contents: + +```json +{ + "schemaVersion": "guardscan.channel-catalog.v1", + "source": { + "repository": "ntanwir10/GuardScan", + "version": "1.1.0", + "tag": "v1.1.0", + "commit": "GUARDSCAN_SOURCE_COMMIT", + "manifestUrl": "IMMUTABLE_RELEASE_MANIFEST_URL", + "manifestSha256": "RELEASE_MANIFEST_SHA256" + }, + "generator": { + "repository": "ntanwir10/GuardScan", + "commit": "GENERATOR_COMMIT" + }, + "files": { + "Formula/guardscan.rb": { + "sha256": "FORMULA_SHA256" + }, + "bucket/guardscan.json": { + "sha256": "SCOOP_MANIFEST_SHA256" + } + } +} +``` + +After merge, the catalog sends a `catalog_updated` repository dispatch +containing the merged commit and lock digest. The dispatch is only a latency +hint: GuardScan refetches the catalog at that commit and validates the lock, +manifest, and generated file digests before appending evidence to the release +ledger. Scheduled reconciliation repeats this check every 30 minutes, so a +missed dispatch cannot create permanent drift. + +Reconciliation is idempotent and fail-closed: + +- missing or older catalog state opens or reuses the deterministic update PR; +- the exact expected lock and file digests materialize as `verified`; +- different digests for the same release open a release-integrity incident; +- an unexpected newer catalog version stops automated mutation for review. + +The ledger records the catalog repository, merged commit, pull-request number, +lock digest, manifest digest, and channel-specific path/digest. Channel remote +identities are: + +```text +github:ntanwir10/homebrew-tap@COMMIT#Formula/guardscan.rb +github:ntanwir10/homebrew-tap@COMMIT#bucket/guardscan.json +``` + +This is strong convergence, not a cross-repository atomic transaction: the +release remains incomplete while the catalog is behind. A rollback is another +generated catalog PR pointing to a verified known-good release plus append-only +ledger events. The repositories are intentionally not connected with +submodules, subtrees, mirroring, or bidirectional synchronization. + ## Maintainer interface Run from `cli/`, or use `npm run release -- `: @@ -143,7 +225,7 @@ The train: 2. creates a candidate commit containing only RC identity changes; 3. creates `v1.1.0-rc.1` with the release GitHub App; 4. builds, signs, attests, and verifies every artifact; -5. publishes npm under `next`, GitHub as a prerelease, TestPyPI then PyPI, and preview tap/bucket branches; +5. publishes npm under `next`, GitHub as a prerelease, TestPyPI then PyPI, and the shared catalog preview branch `channel-preview/v1.1.0-rc.1`; 6. renders and validates WinGet/Chocolatey without publishing an RC; 7. records `publishedAt` and hourly canary evidence; 8. reconciles every 30 minutes. @@ -163,7 +245,7 @@ yarn dlx guardscan bun add -g guardscan bunx guardscan brew install ntanwir10/tap/guardscan -scoop bucket add guardscan https://github.com/ntanwir10/scoop-bucket +scoop bucket add guardscan https://github.com/ntanwir10/homebrew-tap scoop install guardscan winget install --exact --id NaumanTanwir.GuardScan choco install guardscan @@ -173,6 +255,18 @@ pipx install guardscan-cli The npm package requires Node 22 or newer even when invoked by Bun. The standalone and wheel channels include the runtime. +The one-part command `brew install guardscan` becomes available only after +GuardScan is accepted into Homebrew Core and passes a clean public-Core canary. +That optional path is submitted after a stable release, does not block release +completion, and uses a source-building Core formula with Homebrew's `node` +dependency and `std_npm_args`. Until acceptance, documentation keeps +`brew install ntanwir10/tap/guardscan` as the primary command; afterward, the +first-party tap remains the supported fallback. + +The optional `homebrew-core` channel uses the normal append-only +`submitted -> accepted -> verified` states. Submission is not acceptance, and +acceptance is not public verification. + ## Recovery Before stable promotion, any failed build, signature, digest, canary, vulnerability, or source-head check stops the train. The correction is a new `rc.N`. @@ -180,11 +274,13 @@ Before stable promotion, any failed build, signature, digest, canary, vulnerabil After stable publication: - immutable GitHub assets are retained and marked superseded; -- Homebrew/Scoop redirect to a known-good native release or remove the new listing; +- Homebrew/Scoop move together through a generated shared-catalog PR to a known-good native release, or remove the new listing; - PyPI is yanked where authorized; - npm is deprecated and moved forward through a patch; - Chocolatey is unlisted/superseded; - WinGet receives a corrective manifest; - a higher patch version is prepared from selected known-good source. -A release is complete only when every selected channel materializes as `verified`. +A release is complete only when every selected blocking channel materializes +as `verified`. Optional Homebrew Core submission/acceptance is tracked +separately and never blocks the release train. diff --git a/docs/RELEASE_ONBOARDING.md b/docs/RELEASE_ONBOARDING.md index dc4b3aa..80cdae2 100644 --- a/docs/RELEASE_ONBOARDING.md +++ b/docs/RELEASE_ONBOARDING.md @@ -5,14 +5,23 @@ The repository contains the zero-touch release implementation. The following pro ## GitHub - Reauthenticate `gh` as `ntanwir10`. -- Create `ntanwir10/homebrew-tap` and `ntanwir10/scoop-bucket` as public repositories. +- Create `ntanwir10/homebrew-tap` as the public shared Homebrew/Scoop catalog. - Create `guardscan-release-bot` as a GitHub App. -- Grant the App GuardScan contents/pull-request/workflow access and contents/pull-request access on the tap and bucket. +- Grant the App Actions, Contents, Pull requests, Issues, and Workflows + **write** permission plus Metadata **read** permission. +- Install the App only on GuardScan and `ntanwir10/homebrew-tap`. - Store `RELEASE_APP_ID` as a repository variable and `RELEASE_APP_PRIVATE_KEY` as a secret. -- Create and protect `release-ledger`; require the App identity for writes. +- Seed the orphan `release-ledger` branch from + `.github/release-ledger/active-versions.json`, then protect the branch and + require the App identity for writes. Do not copy application source onto the + ledger branch. - Protect `v*` tags so only the release App can create them. - Enable immutable releases for GuardScan. -- Require the full `Release gate` status on the stable release PR. +- Enable squash merge and auto-merge, and require the full `Release gate` + status on the stable release PR. +- Set repository variable `RELEASE_AUTOMATION_ENABLED=false` until every + onboarding rehearsal below passes. Scheduled reconciliation and canaries + must remain dormant while it is false. Create environments without manual reviewers: @@ -25,19 +34,62 @@ Create environments without manual reviewers: - `winget` - `chocolatey` -Restrict them to the release workflows and protected candidate/stable tags. Fork pull requests must not receive environment secrets or OIDC tokens. +Allow protected branch `main` in these environments because the authorized +`workflow_dispatch` caller runs from `main`; add protected candidate/stable tags +where an environment needs them. Fork pull requests must not receive +environment secrets or OIDC tokens. + +The reusable build and publish workflows run in the security context of their +caller. Environment and OIDC policies therefore identify +`.github/workflows/release-train.yml`, not the called reusable workflow. + +## Shared Homebrew and Scoop catalog + +Initialize `ntanwir10/homebrew-tap` with: + +```text +Formula/guardscan.rb +bucket/guardscan.json +channel-lock.json +.github/workflows/verify.yml +``` + +Protect catalog `main`; require pull requests and the catalog verification +check. Stable metadata is merged only to `main`. RC metadata lives on temporary +`channel-preview/vVERSION` branches. Install the release App on this repository +so it can open and update one generated PR containing both package-manager +files and their cryptographic lock. Add the same `RELEASE_APP_ID` variable and +`RELEASE_APP_PRIVATE_KEY` secret to the catalog so its post-merge workflow can +mint a short-lived cross-repository dispatch token; do not store a personal +access token for this notification. + +Configure the catalog's post-merge workflow to send `catalog_updated` to +GuardScan with the merged commit and lock digest. This notification does not +authorize a ledger transition: GuardScan must refetch that exact commit and +validate `channel-lock.json`, the release-manifest digest, and both generated +file digests. The 30-minute GuardScan reconciliation schedule is the recovery +path for missed dispatches and drift. + +Do not add a Git submodule, subtree, repository mirror, or reverse update from +the catalog into GuardScan. GuardScan is authoritative; the catalog is a +generated projection. ## npm - Configure trusted publishing for package `guardscan`. -- Bind it exactly to `ntanwir10/GuardScan`, `.github/workflows/release-publish.yml`, and environment `npm-publish`. +- Bind it exactly to `ntanwir10/GuardScan`, + `.github/workflows/release-train.yml`, and environment `npm-publish`. +- Confirm the release job installs its pinned npm version at `11.5.1` or newer + before the trusted-publishing rehearsal; the npm bundled with Node 22 is not + sufficient for this contract. - Do not retain an npm token fallback after OIDC succeeds. ## TestPyPI and PyPI - Reserve `guardscan-cli`. - Configure pending trusted publishers for both TestPyPI and PyPI. -- Bind them exactly to `ntanwir10/GuardScan`, `.github/workflows/release-publish.yml`, and environment `pypi`. +- Bind them exactly to `ntanwir10/GuardScan`, + `.github/workflows/release-train.yml`, and environment `pypi`. ## Apple @@ -74,6 +126,16 @@ Grant only the Artifact Signing Certificate Profile Signer role needed by the fe WinGet review and Chocolatey validation, verification, VirusTotal, and moderation are external states. The ledger keeps them `submitted` until public installation passes. +## First `1.1.0` bootstrap exception + +The first stable train does not ask Release Please to regenerate `1.1.0`. +The protected `release/1.1.0` pull request is the bootstrap release source +because the previous `main` baseline and the new Release Please seed disagree +about whether `1.1.0` is already prepared. Derive `1.1.0-rc.1` from that exact +PR head, require the normal release gates, and merge/tag it through the release +train. After `v1.1.0` is verified, align the Release Please manifest to `1.1.0` +so subsequent stable release PRs follow the normal automated path. + ## Expiry monitoring Configure provider notifications for: @@ -84,4 +146,8 @@ Configure provider notifications for: - Chocolatey API key validity; - WinGet token expiry or revoked CLA status. -No later release requires a human promotion click. Only provider-mandated identity, MFA, legal, certificate-renewal, or moderator requests remain human boundaries. +After all rehearsals pass, set `RELEASE_AUTOMATION_ENABLED=true`. No later +release requires a human promotion click. Only provider-mandated identity, MFA, +legal, certificate-renewal, account verification, or moderator requests remain +human boundaries; automation must report those states rather than claiming +completion. diff --git a/tasks/plan.md b/tasks/plan.md index 603cd58..764d94a 100644 --- a/tasks/plan.md +++ b/tasks/plan.md @@ -51,6 +51,18 @@ Ship GuardScan through trustworthy, testable installation channels without creat 10. **Generated adapters with checked-in review diffs.** Homebrew, Scoop, WinGet, Chocolatey, optional PyPI, and documentation snippets are rendered from templates plus the release manifest. Automation opens reviewable update pull requests instead of directly mutating every downstream stable channel. 11. **Idempotent, resumable publication.** Each publish step records immutable input/output identity and treats an already-published matching artifact as success; a mismatched artifact is a hard failure. A retry resumes from verified state and never rebuilds or overwrites the version. 12. **Cost-aware verification tiers.** Pull requests run fast package and generator checks, release candidates run the full platform/artifact matrix, stable promotion reuses those exact tested artifacts, and scheduled canaries use a minimal representative matrix. +13. **One authoritative repository and one shared generated catalog.** + GuardScan owns release state. `ntanwir10/homebrew-tap` contains both + `Formula/guardscan.rb` and `bucket/guardscan.json`, bound by + `channel-lock.json`; it never changes GuardScan state directly. A + post-merge dispatch reduces latency and 30-minute reconciliation guarantees + convergence. The repositories do not use submodules, subtrees, mirroring, + or bidirectional synchronization. +14. **Homebrew Core is an optional discovery layer.** The first-party tap is + the stable fallback. After a stable release, automation may submit a + source-building Homebrew Core formula using Homebrew `node` and + `std_npm_args`; Core acceptance and its public canary are tracked but do not + block the release. ## Target channel matrix @@ -62,8 +74,9 @@ Ship GuardScan through trustworthy, testable installation channels without creat | Yarn Classic | Reuse npm package; legacy compatibility only | `yarn global add guardscan` | Best effort, tested separately | | Bun | Reuse npm package; Node remains required until proven otherwise | `bun install --global guardscan` or `bunx guardscan@VERSION` | Phase 1 preview until matrix passes | | GitHub Releases | Signed/attested archives per OS/CPU plus manifest/checksums | Download a versioned archive | Phase 2 canonical binary channel | -| Homebrew | First-party tap referencing immutable release assets | `brew install ntanwir10/tap/guardscan` | Phase 3; core later | -| Scoop | JSON manifest referencing Windows portable archive | `scoop install guardscan` via first-party bucket | Phase 3 | +| Homebrew | Formula in the shared first-party catalog referencing immutable release assets | `brew install ntanwir10/tap/guardscan` | Phase 3; Core later | +| Homebrew Core | Source-building formula using Homebrew `node` | `brew install guardscan` | Optional after stable acceptance and public canary | +| Scoop | JSON manifest in the same shared catalog referencing the Windows portable archive | `scoop bucket add guardscan https://github.com/ntanwir10/homebrew-tap`, then `scoop install guardscan` | Phase 3 | | WinGet | Community manifest referencing signed Windows artifact | `winget install .GuardScan` | Phase 3 after binary stability | | Chocolatey | `.nupkg` referencing or embedding the official Windows artifact | `choco install guardscan` | Phase 3 after binary stability | | PyPI/pipx | Platform wheels bundling the exact standalone binary | `pipx install guardscan-cli` (name to be reserved) | Phase 4 decision gate | @@ -184,16 +197,28 @@ Ship GuardScan through trustworthy, testable installation channels without creat ### GS-DIST-301 β€” Launch a first-party Homebrew tap - **Depends on:** GS-DIST-204, GS-DIST-205, GS-DIST-206, GS-DIST-003. -- **Files/systems:** preferably a dedicated `ntanwir10/homebrew-tap` repository, formula template/update automation, release docs. -- **Scope:** create a formula or tap-specific binary adapter using immutable versioned assets and checksums; support Apple Silicon, Intel macOS, and Linuxbrew where artifacts exist; avoid self-update behavior. +- **Files/systems:** shared `ntanwir10/homebrew-tap` catalog, formula + template/update automation, `channel-lock.json`, release docs. +- **Scope:** generate `Formula/guardscan.rb` as a binary adapter using immutable + versioned assets and checksums; support Apple Silicon, Intel macOS, and + Linuxbrew where artifacts exist; avoid self-update behavior. Update it in the + same catalog PR and lock as the Scoop manifest. - **Acceptance:** `brew audit`, `brew style`, install, test, upgrade, and uninstall pass; formula test executes `guardscan --version` and a safe offline command; formula version/digest match the release manifest. -- **Verification:** test from a clean macOS runner on both available architectures and a Linuxbrew runner. Start with the first-party tap; submit to `homebrew/core` only after stability, usage/notability, and source-build requirements are met. +- **Verification:** test from a clean macOS runner on both available + architectures and a Linuxbrew runner. After a stable release, optionally + submit a separate source-building formula to `homebrew/core` using Homebrew + `node` and `std_npm_args`; only advertise `brew install guardscan` after Core + acceptance and a clean public canary. ### GS-DIST-302 β€” Launch Scoop and prepare WinGet - **Depends on:** GS-DIST-204, GS-DIST-205, GS-DIST-206, GS-DIST-003. -- **Files/systems:** first-party Scoop bucket, WinGet manifests or submission automation. -- **Scope:** create architecture-aware manifests with immutable URLs and SHA-256; map the executable to `guardscan`; define update automation and retained-version behavior. +- **Files/systems:** `bucket/guardscan.json` in the shared + `ntanwir10/homebrew-tap` catalog, WinGet manifests or submission automation. +- **Scope:** create an architecture-aware Scoop manifest with immutable URLs + and SHA-256; map the executable to `guardscan`; generate it in the same + catalog PR and cryptographic lock as the Homebrew formula; define update + automation and retained-version behavior. - **Acceptance:** Scoop install/update/uninstall and `checkver` pass; WinGet manifests validate and pass Windows Sandbox install/upgrade/uninstall before submission. - **Verification:** test Windows without Node installed, and verify the executable hash against the release manifest before and after each adapter install. @@ -238,7 +263,10 @@ Ship GuardScan through trustworthy, testable installation channels without creat - **Depends on:** GS-DIST-103, GS-DIST-204, GS-DIST-500. - **Files:** split CI/release workflows, GitHub environments, release scripts. - **Scope:** compose small reusable workflows for validation, target builds, artifact tests, signing/attestation, draft release, registry publication, adapter updates, and canaries. Use protected-tag and environment gates, a single-release concurrency lock, matrix builds for independent targets, and immutable artifact handoffs so stable promotion never rebuilds release inputs. -- **Acceptance:** version/tag mismatch, duplicate version, missing artifact, failed signature, failed smoke, failed approval, or changed artifact identity blocks promotion; reruns never overwrite a released version; channel status and artifact lineage are visible in a concise job summary. +- **Acceptance:** version/tag mismatch, duplicate version, missing artifact, + failed signature, failed smoke, denied machine promotion policy, or changed + artifact identity blocks promotion; reruns never overwrite a released version; + channel status and artifact lineage are visible in a concise job summary. - **Verification:** full prerelease dry run with an intentionally failed channel, concurrent release attempt, cancellation, and safe retry/resume using the original tested artifacts. ### GS-DIST-502 β€” Harden the release supply chain @@ -276,16 +304,34 @@ Ship GuardScan through trustworthy, testable installation channels without creat ### GS-DIST-506 β€” Automate downstream channel update pull requests - **Depends on:** GS-DIST-206, first implementation of each adapter. -- **Files/systems:** release renderers/templates, renderer fixtures, Homebrew tap/Scoop bucket/packaging repositories, documentation snippets. -- **Scope:** use GS-DIST-206 output to open reviewable downstream pull requests with machine-readable provenance back to the source release; apply channel-specific moderation/approval policy without duplicating renderer logic. -- **Acceptance:** no adapter contains a hand-copied version, URL, or checksum; `render --check` fails on drift; unchanged output creates no commit or pull request; a new channel uses the same narrow renderer/validator/smoke interface. -- **Verification:** golden tests for every renderer, native manifest validation, and a dry-run against fixture downstream repositories. +- **Files/systems:** release renderers/templates, renderer fixtures, shared + `ntanwir10/homebrew-tap` catalog, moderated-registry packaging sources, + documentation snippets. +- **Scope:** use GS-DIST-206 output to open one reviewable shared-catalog PR + containing the Homebrew formula, Scoop manifest, and + `guardscan.channel-catalog.v1` lock. Catalog CI refetches the immutable + release manifest and rerenders from the exact GuardScan source commit. + Catalog merge sends a `catalog_updated` dispatch as a hint; GuardScan + independently validates the merged commit and reconciles every 30 minutes. +- **Acceptance:** no adapter contains a hand-copied version, URL, or checksum; + `render --check` fails on drift; unchanged output creates no commit or pull + request; same-release digest conflicts open an integrity incident; remote + identities bind the catalog commit and channel path. The catalog never + independently updates GuardScan. +- **Verification:** golden tests for every renderer, lock schema/digest tests, + native manifest validation, missed-dispatch recovery, catalog + older/exact/conflicting/unexpected-newer fixtures, and a dry-run against the + shared catalog fixture. ### GS-DIST-507 β€” Make releases dry-runnable, observable, and safely resumable - **Depends on:** GS-DIST-500, GS-DIST-501. - **Files/systems:** release-state schema, orchestration scripts, workflow summaries/artifacts, runbook. -- **Scope:** persist a release ledger containing commit, version, artifact digests, signatures, attestations, per-channel publication identity, approvals, and errors; add `dry-run`, `status`, and `resume` paths; query remote state before every mutation; never infer completion only from a previous job's exit code. +- **Scope:** persist a release ledger containing commit, version, artifact + digests, signatures, attestations, per-channel publication identity, machine + policy decisions, and errors; add `dry-run`, `status`, and `resume` paths; + query remote state before every mutation; never infer completion only from a + previous job's exit code. - **Acceptance:** maintainers can determine exactly what published and what remains from one status command; retrying a completed matching step is harmless; conflicting remote state stops with an actionable error; dry-run performs every validation without publishing. - **Verification:** deterministic scenarios for no-op rerun, failure before publication, failure after one channel, lost CI job state, remote match, remote conflict, and successful resume. @@ -310,7 +356,11 @@ Ship GuardScan through trustworthy, testable installation channels without creat - **GHCR image:** valuable for CI and hermetic use. Run as a non-root user, support read-only repository mounts plus an explicit output mount, publish multi-architecture images by digest, and attest them. - **Shell/PowerShell installer:** only after signed standalone assets exist. Default to a user-local directory, accept an explicit version, verify checksum/signature, and avoid `curl | sh` as the only documented path. - **APT/RPM:** add only after usage justifies repository signing, mirror operations, distro compatibility, and long-term update maintenance. -- **Homebrew core:** pursue after the first-party tap is stable and GuardScan meets Homebrew's notability and source-build expectations. +- **Homebrew Core:** submit only after a stable first-party release. Build from + the tested npm source tarball with Homebrew `node` and `std_npm_args`; keep + Core acceptance non-blocking and switch the primary install command to + `brew install guardscan` only after public-Core verification. The tap remains + the fallback. ## Release checkpoints @@ -319,7 +369,9 @@ Ship GuardScan through trustworthy, testable installation channels without creat - GS-DIST-001 through GS-DIST-103 plus GS-DIST-500 complete. - Release `1.1.0` or its successor to npm only after clean exact-artifact tests. - Document npm, pnpm, Yarn, and Bun commands with accurate Node prerequisites. -- A routine Node-only release can be prepared through one release PR, exercised in dry-run mode, approved, and resumed without manually editing version or registry metadata. +- A routine Node-only release can be prepared through one release PR, exercised + in dry-run mode, machine-gated, and resumed without manually editing version + or registry metadata. ### Checkpoint B β€” Binary foundation ready @@ -329,7 +381,8 @@ Ship GuardScan through trustworthy, testable installation channels without creat ### Checkpoint C β€” Native channels ready -- First-party Homebrew tap and Scoop pass. +- The first-party Homebrew formula and Scoop manifest pass together from the + shared, cryptographically locked catalog. - WinGet and Chocolatey submissions follow after Windows signing and stable binary canaries. - Channel rollback runbooks are exercised. @@ -361,8 +414,11 @@ Ship GuardScan through trustworthy, testable installation channels without creat - Every advertised installation command has passed install, version/help, offline smoke, upgrade, and uninstall on its supported platforms. - npm and standalone artifacts have verifiable provenance; native artifacts have checksums and platform signatures where applicable. - All channels resolve to the same product version and artifact identity. -- Routine releases require one reviewed release PR and an explicit stable-promotion approval, not manual edits across package-manager files. +- Routine releases require one reviewed release PR and a machine-approved + 24-hour RC soak, not manual edits across package-manager files. - The release can be dry-run, inspected, retried, and resumed from persisted machine-readable state without rebuilding or overwriting artifacts. -- Channel manifests and install documentation are generated and drift-checked from the canonical release manifest. +- Channel manifests and install documentation are generated and drift-checked + from the canonical release manifest; shared-catalog state is bound by its lock + and exact merged commit. - Privacy, offline, safe-execution, SBOM, and exit-code contracts remain unchanged across channels. - Documentation, support ownership, monitoring, and rollback are live before stable channel labels are applied. diff --git a/tasks/todo.md b/tasks/todo.md index 219eeb8..6224a0b 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -6,6 +6,9 @@ - [x] Add an append-only hash-chained ledger, idempotent remote classification, integrity incidents, and forward-fix rollback planning. - [x] Build the full npm/native/PyPI manifest, deterministic archives and wheels, artifact SBOMs, signed checksums, and build attestations. - [x] Implement OIDC npm/PyPI publication plus GitHub, Homebrew, Scoop, WinGet, and Chocolatey workflows. +- [x] Consolidate Homebrew and Scoop into the generated + `ntanwir10/homebrew-tap` shared-catalog contract with a cryptographic lock, + dispatch hint, and 30-minute reconciliation. - [x] Add hourly fail-closed canaries for every selected install contract and serialize protected ledger updates. - [x] Add release CLI interfaces for build, manifest, publish, verify, reconcile, promote, rollback, and status. - [x] Pass local typecheck, build, 787-test coverage suite, 53 release contracts, lint ratchet, production audit, and packed-artifact inspection. @@ -43,8 +46,13 @@ ## Checkpoint C β€” native package managers -- [ ] GS-DIST-301 create and test the first-party Homebrew tap on macOS and Linuxbrew. -- [ ] GS-DIST-302 create and test a first-party Scoop manifest/bucket. +- [ ] GS-DIST-301 create and test `Formula/guardscan.rb` in the shared + `ntanwir10/homebrew-tap` catalog on macOS and Linuxbrew. +- [ ] GS-DIST-302 create and test `bucket/guardscan.json` in that same shared + catalog, including lock verification and missed-dispatch reconciliation. +- [ ] Submit the non-blocking source-building Homebrew Core formula after the + stable release; advertise `brew install guardscan` only after acceptance and + a public canary. - [ ] GS-DIST-302 validate and submit WinGet manifests after Windows artifact stability. - [ ] GS-DIST-303 build, locally test, submit, and obtain approval for the Chocolatey package. - [ ] Generate adapter versions, URLs, and hashes from the canonical release manifest. From 974714dac8db44c42d9088892bddb69e2b542a6d Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sat, 25 Jul 2026 23:58:30 -0400 Subject: [PATCH 05/23] fix(ci): keep security override compatible with npm 10 --- cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index ce2af0d..f12c02b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -128,6 +128,6 @@ }, "overrides": { "brace-expansion@<1.1.16": "1.1.16", - "qs@<6.15.3": "6.15.3" + "qs": "6.15.3" } } From 393c4e5e98f8c54d6fd0b42107f7e92c24912359 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:01:08 -0400 Subject: [PATCH 06/23] fix(ci): track lint ratchet evaluator --- .gitignore | 1 + cli/scripts/eslint-ratchet-lib.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 cli/scripts/eslint-ratchet-lib.js diff --git a/.gitignore b/.gitignore index c53992c..b242cae 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ ci-gitconfig ci-netrc cli/scripts/* !cli/scripts/eslint-ratchet.js +!cli/scripts/eslint-ratchet-lib.js !cli/scripts/eslint-baseline.json !cli/scripts/clean-dist.js !cli/scripts/package-smoke.js diff --git a/cli/scripts/eslint-ratchet-lib.js b/cli/scripts/eslint-ratchet-lib.js new file mode 100644 index 0000000..600776c --- /dev/null +++ b/cli/scripts/eslint-ratchet-lib.js @@ -0,0 +1,30 @@ +'use strict'; + +function evaluateBaseline(current, baseline, fileExists) { + const regressions = []; + const improvements = []; + + for (const [file, counts] of Object.entries(current)) { + const allowed = baseline[file] || { errors: 0, warnings: 0 }; + if (counts.errors > allowed.errors || counts.warnings > allowed.warnings) { + regressions.push( + `${file}: ${counts.errors} errors/${counts.warnings} warnings ` + + `(baseline ${allowed.errors}/${allowed.warnings})` + ); + } else if (counts.errors < allowed.errors || counts.warnings < allowed.warnings) { + improvements.push(`${file}: ${counts.errors} errors/${counts.warnings} warnings`); + } + } + + for (const [file, allowed] of Object.entries(baseline)) { + if (!fileExists(file)) { + regressions.push(`${file}: baseline source file is missing; regenerate the reviewed baseline`); + } else if (!current[file] && (allowed.errors > 0 || allowed.warnings > 0)) { + improvements.push(`${file}: clean`); + } + } + + return {regressions, improvements}; +} + +module.exports = {evaluateBaseline}; From c3deb3e2b8e4ab2129f7ee29300e1fc55050f324 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:05:02 -0400 Subject: [PATCH 07/23] test(cli): remove filesystem ordering assumption --- cli/__tests__/utils/private-state.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cli/__tests__/utils/private-state.test.ts b/cli/__tests__/utils/private-state.test.ts index 66add52..7f16c03 100644 --- a/cli/__tests__/utils/private-state.test.ts +++ b/cli/__tests__/utils/private-state.test.ts @@ -73,10 +73,12 @@ describe('private state persistence', () => { for (let index = 0; index < 5; index++) { fs.writeFileSync(path.join(root, `${index}.json`), '{}'); } - expect(listDirectoryBounded(root, 3)).toEqual({ - names: ['0.json', '1.json', '2.json'], - truncated: true, - }); + const listing = listDirectoryBounded(root, 3); + expect(listing.truncated).toBe(true); + expect(listing.names).toHaveLength(3); + expect(listing.names).toEqual([...listing.names].sort((left, right) => left.localeCompare(right))); + expect(new Set(listing.names).size).toBe(3); + expect(listing.names.every(name => /^[0-4]\.json$/.test(name))).toBe(true); }); it('can process every directory entry with bounded callback memory', () => { From 60dd681d1c5c6dd701f3f5afde33d389ec44f46f Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:10:10 -0400 Subject: [PATCH 08/23] fix(ci): normalize Windows workflow and glob paths --- cli/__tests__/scripts/release-workflows.test.ts | 2 +- cli/src/core/loc-counter.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cli/__tests__/scripts/release-workflows.test.ts b/cli/__tests__/scripts/release-workflows.test.ts index 88f5629..008d989 100644 --- a/cli/__tests__/scripts/release-workflows.test.ts +++ b/cli/__tests__/scripts/release-workflows.test.ts @@ -17,7 +17,7 @@ const releaseWorkflows = [ ]; function workflowSource(filename: string): string { - return fs.readFileSync(path.join(workflowRoot, filename), 'utf8'); + return fs.readFileSync(path.join(workflowRoot, filename), 'utf8').replace(/\r\n?/g, '\n'); } describe('zero-touch release workflow contracts', () => { diff --git a/cli/src/core/loc-counter.ts b/cli/src/core/loc-counter.ts index b1c23b2..d1801f9 100644 --- a/cli/src/core/loc-counter.ts +++ b/cli/src/core/loc-counter.ts @@ -105,7 +105,9 @@ export class LOCCounter { '**/*.{js,jsx,ts,tsx,py,java,go,rs,c,cpp,h,hpp,cs,rb,php,swift,kt,scala,sh,bash}', ]; - const globPatterns = patterns || defaultPatterns; + const globPatterns = (patterns || defaultPatterns).map(pattern => + path.sep === '\\' ? pattern.replace(/\\/g, '/') : pattern + ); const files = await fastGlob(globPatterns, { cwd: process.cwd(), absolute: true, // Get absolute paths first @@ -115,7 +117,7 @@ export class LOCCounter { // Convert to relative paths and filter using ignore patterns const cwd = process.cwd(); return files - .map(file => path.relative(cwd, file)) + .map(file => path.relative(cwd, file).split(path.sep).join('/')) .filter(file => !this.ignoreMatcher.ignores(file)); } From 03029ccc3c56d16c8060045807a6c3fe6b09a394 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:18:35 -0400 Subject: [PATCH 09/23] fix(ci): harden Windows and Bun package canaries --- .../scripts/package-manager-smoke.test.ts | 7 ++++ cli/scripts/package-manager-smoke.js | 28 ++++++++++--- cli/scripts/package-smoke.js | 42 +++++++++++++------ 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/cli/__tests__/scripts/package-manager-smoke.test.ts b/cli/__tests__/scripts/package-manager-smoke.test.ts index 2141445..0f8f682 100644 --- a/cli/__tests__/scripts/package-manager-smoke.test.ts +++ b/cli/__tests__/scripts/package-manager-smoke.test.ts @@ -3,11 +3,13 @@ const { installArgs, parseManager, parseTarball, + scanArgsFor, } = require('../../scripts/package-manager-smoke') as { execArgs: (manager: string, args: string[]) => string[]; installArgs: (manager: string, tarball: string) => string[]; parseManager: (args: string[]) => string; parseTarball: (args: string[]) => string | undefined; + scanArgsFor: (manager: string, output: string) => string[]; }; describe('package-manager smoke command contracts', () => { @@ -28,6 +30,11 @@ describe('package-manager smoke command contracts', () => { expect(installArgs('yarn', '/tmp/guardscan.tgz')).toEqual(['add', '/tmp/guardscan.tgz']); }); + it('makes Bun partial SBOM inventory explicit without weakening other canaries', () => { + expect(scanArgsFor('bun', '/tmp/scan.json')).toContain('--allow-partial'); + expect(scanArgsFor('npm', '/tmp/scan.json')).not.toContain('--allow-partial'); + }); + it('rejects omitted and unknown managers', () => { expect(() => parseManager([])).toThrow(/--manager must be one of/); expect(() => parseManager(['--manager', 'pip'])).toThrow(/--manager must be one of/); diff --git a/cli/scripts/package-manager-smoke.js b/cli/scripts/package-manager-smoke.js index f481f9d..2179ead 100644 --- a/cli/scripts/package-manager-smoke.js +++ b/cli/scripts/package-manager-smoke.js @@ -105,14 +105,15 @@ function main(argv = process.argv.slice(2)) { assert(help.stdout.includes('scan'), `${manager} CLI help is missing scan`); const output = path.join(project, 'scan.json'); - runCli(manager, [ - '--no-telemetry', 'scan', '--offline', '--no-cve', '--skip-tests', '--skip-ai', - '--format', 'json', '--output', output, - ], project, env); + runCli(manager, scanArgsFor(manager, output), project, env); const scan = JSON.parse(fs.readFileSync(output, 'utf8')); assert(scan.schemaVersion === 'guardscan.scan.v1', `${manager} emitted the wrong scan schema`); assert(scan.run?.executionMode === 'static-analysis', `${manager} did not preserve safe execution mode`); assert(scan.run?.offline === true, `${manager} did not preserve offline mode`); + assert( + scan.run?.allowPartial === (manager === 'bun'), + `${manager} emitted the wrong partial-inventory policy` + ); const telemetry = runCli(manager, ['--no-telemetry', 'telemetry', 'status'], project, env); assert(telemetry.stdout.includes('Consent: disabled'), `${manager} did not preserve telemetry opt-out`); @@ -126,6 +127,15 @@ function runCli(manager, args, cwd, env) { return run(commandFor(manager), execArgs(manager, args), cwd, env); } +function scanArgsFor(manager, output) { + const args = [ + '--no-telemetry', 'scan', '--offline', '--no-cve', '--skip-tests', '--skip-ai', + '--format', 'json', '--output', output, + ]; + if (manager === 'bun') args.splice(2, 0, '--allow-partial'); + return args; +} + function run(command, args, cwd, env) { const result = spawnSync(command, args, { cwd, @@ -156,4 +166,12 @@ if (require.main === module) { } } -module.exports = {commandFor, execArgs, installArgs, main, parseManager, parseTarball}; +module.exports = { + commandFor, + execArgs, + installArgs, + main, + parseManager, + parseTarball, + scanArgsFor, +}; diff --git a/cli/scripts/package-smoke.js b/cli/scripts/package-smoke.js index b3eb40e..6b1b6e4 100644 --- a/cli/scripts/package-smoke.js +++ b/cli/scripts/package-smoke.js @@ -27,8 +27,7 @@ try { name: 'guardscan-package-smoke', version: '1.0.0', private: true, })); fs.writeFileSync(path.join(project, 'index.js'), 'module.exports = () => 42;\n'); - run( - npmCommand(), + runNpm( [ 'install', '--global', @@ -77,6 +76,14 @@ try { ? path.join(globalPrefix, 'guardscan.cmd') : path.join(globalPrefix, 'bin', 'guardscan'); assert(fs.existsSync(cli), `global install did not create the GuardScan shim at ${cli}`); + const cliCommand = process.platform === 'win32' ? process.execPath : cli; + const cliPrefix = process.platform === 'win32' + ? [path.join(installedPackage, 'dist', 'index.js')] + : []; + if (process.platform === 'win32') { + const shim = fs.readFileSync(cli, 'utf8').replace(/\\/g, '/'); + assert(shim.includes('node_modules/guardscan/dist/index.js'), 'Windows shim has the wrong target'); + } const env = { ...npmEnv, GUARDSCAN_HOME: home, @@ -84,24 +91,24 @@ try { USERPROFILE: home, GUARDSCAN_NO_TELEMETRY: 'true', }; - const version = run(cli, ['--version'], project, env); + const version = run(cliCommand, [...cliPrefix, '--version'], project, env); const versionLines = version.stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean); const reportedVersion = versionLines.at(-1); assert( reportedVersion === expectedVersion, `installed CLI reported ${JSON.stringify(reportedVersion)}; expected ${expectedVersion}` ); - const help = run(cli, ['--help'], project, env); + const help = run(cliCommand, [...cliPrefix, '--help'], project, env); for (const command of ['scan', 'security', 'vuln|cve', 'telemetry', 'cache']) { assert(help.stdout.includes(command), `installed CLI help is missing ${command}`); } - run(cli, ['--no-telemetry', 'init'], project, env); + run(cliCommand, [...cliPrefix, '--no-telemetry', 'init'], project, env); const config = parseJsonLikeYaml(path.join(home, '.guardscan', 'config.yml')); assert(config.telemetryEnabled === false, 'installed CLI did not default telemetry off'); assert(config.offlineMode === true, 'installed CLI did not default offline mode on'); const output = path.join(project, 'scan.json'); - run(cli, [ + run(cliCommand, [...cliPrefix, '--no-telemetry', 'scan', '--offline', @@ -126,10 +133,10 @@ try { const spdxOutput = path.join(project, 'sbom-spdx.json'); const cycloneDxOutput = path.join(project, 'sbom-cyclonedx.json'); - run(cli, [ + run(cliCommand, [...cliPrefix, '--no-telemetry', '--offline', 'sbom', '--format', 'spdx', '--output', spdxOutput, ], project, env); - run(cli, [ + run(cliCommand, [...cliPrefix, '--no-telemetry', '--offline', 'sbom', '--format', 'cyclonedx', '--output', cycloneDxOutput, ], project, env); validateSboms( @@ -137,7 +144,12 @@ try { JSON.parse(fs.readFileSync(cycloneDxOutput, 'utf8')) ); - const telemetry = run(cli, ['--no-telemetry', 'telemetry', 'status'], project, env); + const telemetry = run( + cliCommand, + [...cliPrefix, '--no-telemetry', 'telemetry', 'status'], + project, + env + ); assert(telemetry.stdout.includes('Consent: disabled'), 'installed telemetry consent default is wrong'); assert(telemetry.stdout.includes('Pending events: 0'), 'installed telemetry outbox is not empty'); process.stdout.write(`Package smoke passed: ${path.basename(tarball)}\n`); @@ -145,8 +157,13 @@ try { fs.rmSync(tempRoot, { recursive: true, force: true }); } -function npmCommand() { - return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +function runNpm(args, cwd, env) { + const npmCli = process.env.npm_execpath; + if (npmCli && fs.existsSync(npmCli)) { + return run(process.execPath, [npmCli, ...args], cwd, env); + } + assert(process.platform !== 'win32', 'npm_execpath is required for shell-free Windows smoke tests'); + return run('npm', args, cwd, env); } function resolveTarball(argv, destination, env) { @@ -163,8 +180,7 @@ function resolveTarball(argv, destination, env) { assert(stat.isFile() && resolved.endsWith('.tgz'), `invalid package tarball: ${resolved}`); return resolved; } - const packed = run( - npmCommand(), + const packed = runNpm( ['pack', '--json', '--pack-destination', destination], packageRoot, env From 44b5809e5871d089eba5f712288c9210859f86fa Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:27:36 -0400 Subject: [PATCH 10/23] fix(ci): keep Bun inventory canary strict --- .../scripts/package-manager-smoke.test.ts | 14 +++++++++++--- cli/scripts/package-manager-smoke.js | 13 ++++--------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/cli/__tests__/scripts/package-manager-smoke.test.ts b/cli/__tests__/scripts/package-manager-smoke.test.ts index 0f8f682..91298ba 100644 --- a/cli/__tests__/scripts/package-manager-smoke.test.ts +++ b/cli/__tests__/scripts/package-manager-smoke.test.ts @@ -26,13 +26,21 @@ describe('package-manager smoke command contracts', () => { expect(installArgs(manager, '/tmp/guardscan.tgz')).toContain('--ignore-scripts'); }); + it('asks Bun to emit the supported Yarn inventory alongside bun.lock', () => { + expect(installArgs('bun', '/tmp/guardscan.tgz')).toEqual([ + 'add', + '--ignore-scripts', + '--yarn', + '/tmp/guardscan.tgz', + ]); + }); + it('uses Yarn configuration rather than an unsupported install flag', () => { expect(installArgs('yarn', '/tmp/guardscan.tgz')).toEqual(['add', '/tmp/guardscan.tgz']); }); - it('makes Bun partial SBOM inventory explicit without weakening other canaries', () => { - expect(scanArgsFor('bun', '/tmp/scan.json')).toContain('--allow-partial'); - expect(scanArgsFor('npm', '/tmp/scan.json')).not.toContain('--allow-partial'); + it.each(['npm', 'pnpm', 'yarn', 'bun'])('keeps the %s scan canary strict', manager => { + expect(scanArgsFor(manager, '/tmp/scan.json')).not.toContain('--allow-partial'); }); it('rejects omitted and unknown managers', () => { diff --git a/cli/scripts/package-manager-smoke.js b/cli/scripts/package-manager-smoke.js index 2179ead..cb88683 100644 --- a/cli/scripts/package-manager-smoke.js +++ b/cli/scripts/package-manager-smoke.js @@ -20,7 +20,7 @@ function installArgs(manager, tarball) { if (manager === 'npm') return ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball]; if (manager === 'pnpm') return ['add', '--ignore-scripts', tarball]; if (manager === 'yarn') return ['add', tarball]; - if (manager === 'bun') return ['add', '--ignore-scripts', tarball]; + if (manager === 'bun') return ['add', '--ignore-scripts', '--yarn', tarball]; throw new Error(`Unsupported package manager: ${manager}`); } @@ -110,10 +110,7 @@ function main(argv = process.argv.slice(2)) { assert(scan.schemaVersion === 'guardscan.scan.v1', `${manager} emitted the wrong scan schema`); assert(scan.run?.executionMode === 'static-analysis', `${manager} did not preserve safe execution mode`); assert(scan.run?.offline === true, `${manager} did not preserve offline mode`); - assert( - scan.run?.allowPartial === (manager === 'bun'), - `${manager} emitted the wrong partial-inventory policy` - ); + assert(scan.run?.allowPartial === false, `${manager} weakened the partial-inventory policy`); const telemetry = runCli(manager, ['--no-telemetry', 'telemetry', 'status'], project, env); assert(telemetry.stdout.includes('Consent: disabled'), `${manager} did not preserve telemetry opt-out`); @@ -127,13 +124,11 @@ function runCli(manager, args, cwd, env) { return run(commandFor(manager), execArgs(manager, args), cwd, env); } -function scanArgsFor(manager, output) { - const args = [ +function scanArgsFor(_manager, output) { + return [ '--no-telemetry', 'scan', '--offline', '--no-cve', '--skip-tests', '--skip-ai', '--format', 'json', '--output', output, ]; - if (manager === 'bun') args.splice(2, 0, '--allow-partial'); - return args; } function run(command, args, cwd, env) { From 5199fcff1188ba8a33b713f3d53ad21130976d8b Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:36:24 -0400 Subject: [PATCH 11/23] fix(release): retry transient Windows artifact locks --- .../scripts/standalone-builder.test.ts | 42 +++++++++++++++++++ cli/scripts/release/standalone.js | 20 ++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/cli/__tests__/scripts/standalone-builder.test.ts b/cli/__tests__/scripts/standalone-builder.test.ts index f03d3d5..fcb1797 100644 --- a/cli/__tests__/scripts/standalone-builder.test.ts +++ b/cli/__tests__/scripts/standalone-builder.test.ts @@ -7,6 +7,7 @@ const { bundleOptions, externalPackages, hostPlatform, + renameWithTransientRetry, } = require('../../scripts/release/standalone') as { OPTIONAL_EXTERNALS: string[]; PROTOTYPE_SCHEMA: string; @@ -14,6 +15,15 @@ const { bundleOptions: (entryPoint: string, outputFile: string) => Record; externalPackages: (metafile: Record) => string[]; hostPlatform: () => {os: string; arch: string}; + renameWithTransientRetry: ( + source: string, + destination: string, + options?: { + rename?: (source: string, destination: string) => Promise; + wait?: (delay: number) => Promise; + delays?: number[]; + } + ) => Promise; }; function metafile(imports: Array<{path: string; external?: boolean}>): Record { @@ -68,4 +78,36 @@ describe('standalone executable builder contract', () => { expect(['arm64', 'x64']).toContain(platform.arch); expect(path.isAbsolute(process.execPath)).toBe(true); }); + + it.each(['EACCES', 'EBUSY', 'EPERM'])('retries a transient %s atomic publish failure', async code => { + const transient = Object.assign(new Error('temporarily locked'), {code}); + const rename = jest.fn() + .mockRejectedValueOnce(transient) + .mockResolvedValueOnce(undefined); + const wait = jest.fn().mockResolvedValue(undefined); + + await renameWithTransientRetry('/stage', '/release', { + rename, + wait, + delays: [25], + }); + + expect(rename).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledWith(25); + }); + + it('does not retry a non-transient atomic publish failure', async () => { + const permanent = Object.assign(new Error('invalid target'), {code: 'EINVAL'}); + const rename = jest.fn().mockRejectedValue(permanent); + const wait = jest.fn().mockResolvedValue(undefined); + + await expect(renameWithTransientRetry('/stage', '/release', { + rename, + wait, + delays: [25], + })).rejects.toBe(permanent); + + expect(rename).toHaveBeenCalledTimes(1); + expect(wait).not.toHaveBeenCalled(); + }); }); diff --git a/cli/scripts/release/standalone.js b/cli/scripts/release/standalone.js index 34751d6..8edc5a6 100644 --- a/cli/scripts/release/standalone.js +++ b/cli/scripts/release/standalone.js @@ -15,6 +15,8 @@ const OPTIONAL_EXTERNALS = Object.freeze(['chartjs-node-canvas', 'tiktoken']); const MAX_BUNDLE_BYTES = 256 * 1024 * 1024; const MAX_EXECUTABLE_BYTES = 512 * 1024 * 1024; const BUILTIN_MODULES = new Set(builtinModules.flatMap(name => [name, name.replace(/^node:/, '')])); +const TRANSIENT_RENAME_CODES = new Set(['EACCES', 'EBUSY', 'EPERM']); +const RENAME_RETRY_DELAYS_MS = Object.freeze([50, 100, 200, 400, 800, 1000, 1500, 2000]); function hostPlatform() { const osName = {darwin: 'darwin', linux: 'linux', win32: 'windows'}[process.platform]; @@ -103,6 +105,21 @@ function hashRegularFile(file, maxBytes, label) { } } +async function renameWithTransientRetry(source, destination, options = {}) { + const rename = options.rename || fs.promises.rename; + const wait = options.wait || (delay => new Promise(resolve => setTimeout(resolve, delay))); + const delays = options.delays || RENAME_RETRY_DELAYS_MS; + for (let attempt = 0; ; attempt += 1) { + try { + await rename(source, destination); + return; + } catch (error) { + if (!TRANSIENT_RENAME_CODES.has(error?.code) || attempt >= delays.length) throw error; + await wait(delays[attempt]); + } + } +} + function run(command, args, cwd, env) { const result = spawnSync(command, args, { cwd, @@ -297,7 +314,7 @@ async function buildHostPrototype(source, outputDir) { }); fs.rmSync(bundleFile, {force: true}); fs.rmSync(blobFile, {force: true}); - fs.renameSync(stage, resolved); + await renameWithTransientRetry(stage, resolved); return {outputDir: resolved, metadata}; } finally { fs.rmSync(stage, {recursive: true, force: true}); @@ -313,5 +330,6 @@ module.exports = { bundleOptions, externalPackages, hostPlatform, + renameWithTransientRetry, smokeStandalone, }; From f7310733668e7155f8b7b427b38af2fa77dcc848 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:39:13 -0400 Subject: [PATCH 12/23] ci(release): bootstrap an unpublished shared catalog --- .../homebrew-tap/.github/workflows/verify.yml | 39 ++++++++++++++++++- catalog/homebrew-tap/README.md | 6 +++ .../scripts/release-workflows.test.ts | 3 ++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/catalog/homebrew-tap/.github/workflows/verify.yml b/catalog/homebrew-tap/.github/workflows/verify.yml index d9a17a6..e2b178b 100644 --- a/catalog/homebrew-tap/.github/workflows/verify.yml +++ b/catalog/homebrew-tap/.github/workflows/verify.yml @@ -28,6 +28,7 @@ jobs: name: Source and lock integrity runs-on: ubuntu-24.04 outputs: + published: ${{ steps.lock.outputs.published }} source_commit: ${{ steps.lock.outputs.source_commit }} manifest_url: ${{ steps.lock.outputs.manifest_url }} manifest_sha256: ${{ steps.lock.outputs.manifest_sha256 }} @@ -44,10 +45,24 @@ jobs: run: | node <<'NODE' const fs = require('fs'); - const lock = JSON.parse(fs.readFileSync('channel-lock.json', 'utf8')); const fail = message => { throw new Error(message); }; const sha = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); const commit = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value); + const catalogFiles = [ + 'channel-lock.json', + 'Formula/guardscan.rb', + 'bucket/guardscan.json', + ]; + const present = catalogFiles.filter(file => fs.existsSync(file)); + if (present.length === 0) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, 'published=false\n'); + process.stdout.write('Catalog is initialized but has no published GuardScan release.\n'); + process.exit(0); + } + if (present.length !== catalogFiles.length) { + fail(`catalog metadata is partially initialized: ${present.join(', ')}`); + } + const lock = JSON.parse(fs.readFileSync('channel-lock.json', 'utf8')); if (lock.schemaVersion !== 'guardscan.channel-catalog.v1') fail('unsupported channel lock'); if (lock.source?.repository !== 'ntanwir10/GuardScan') fail('unexpected source repository'); if (lock.generator?.repository !== 'ntanwir10/GuardScan') fail('unexpected generator repository'); @@ -66,6 +81,7 @@ jobs: if (!sha(lock.files[filename]?.sha256)) fail(`invalid digest for ${filename}`); } const output = [ + 'published=true', `source_commit=${lock.source.commit}`, `manifest_url=${lock.source.manifestUrl}`, `manifest_sha256=${lock.source.manifestSha256}`, @@ -76,6 +92,7 @@ jobs: NODE - name: Download and authenticate the immutable release manifest + if: steps.lock.outputs.published == 'true' env: MANIFEST_URL: ${{ steps.lock.outputs.manifest_url }} MANIFEST_SHA256: ${{ steps.lock.outputs.manifest_sha256 }} @@ -100,6 +117,7 @@ jobs: NODE - name: Check out the exact GuardScan generator + if: steps.lock.outputs.published == 'true' uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: ntanwir10/GuardScan @@ -108,12 +126,14 @@ jobs: persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + if: steps.lock.outputs.published == 'true' with: node-version: ${{ env.RELEASE_NODE_VERSION }} cache: npm cache-dependency-path: .guardscan-source/cli/package-lock.json - name: Reproduce every generated byte + if: steps.lock.outputs.published == 'true' working-directory: .guardscan-source/cli env: CATALOG_ROOT: ${{ github.workspace }} @@ -143,7 +163,11 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false + - name: Confirm the catalog is awaiting its first stable release + if: needs.integrity.outputs.published != 'true' + run: echo "Homebrew lifecycle will activate with the first generated catalog release." - name: Style, audit, install, invoke, test, and uninstall + if: needs.integrity.outputs.published == 'true' shell: bash run: | brew style Formula/guardscan.rb @@ -161,7 +185,12 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false + - name: Confirm the catalog is awaiting its first stable release + if: needs.integrity.outputs.published != 'true' + shell: pwsh + run: Write-Output 'Scoop lifecycle will activate with the first generated catalog release.' - name: Check out the pinned Scoop installer + if: needs.integrity.outputs.published == 'true' uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: ScoopInstaller/Install @@ -169,6 +198,7 @@ jobs: path: .scoop-installer persist-credentials: false - name: Install the pinned Scoop CLI + if: needs.integrity.outputs.published == 'true' shell: pwsh run: | $scoopRoot = Join-Path $env:RUNNER_TEMP 'scoop' @@ -180,6 +210,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Unable to select the pinned Scoop CLI' } "$scoopRoot\shims" | Out-File $env:GITHUB_PATH -Append - name: Validate, install, invoke, and remove the portable archive + if: needs.integrity.outputs.published == 'true' shell: pwsh run: | $ErrorActionPreference = 'Stop' @@ -215,6 +246,7 @@ jobs: } Remove-Item -LiteralPath $install -Recurse -Force - name: Install, update-check, invoke, and uninstall through Scoop + if: needs.integrity.outputs.published == 'true' shell: pwsh run: | scoop bucket add guardscan-catalog $env:GITHUB_WORKSPACE @@ -232,7 +264,10 @@ jobs: notify: name: Notify authoritative release ledger - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: >- + github.event_name == 'push' + && github.ref == 'refs/heads/main' + && needs.integrity.outputs.published == 'true' needs: [integrity, homebrew, scoop] runs-on: ubuntu-24.04 steps: diff --git a/catalog/homebrew-tap/README.md b/catalog/homebrew-tap/README.md index cb84d5c..0a18ee3 100644 --- a/catalog/homebrew-tap/README.md +++ b/catalog/homebrew-tap/README.md @@ -17,3 +17,9 @@ byte-for-byte from the exact GuardScan commit named in `channel-lock.json`. The catalog is a generated projection, not a source mirror or a second release authority. Its verification workflow is also sourced from this scaffold so that catalog policy changes are reviewed with the release generator. + +Before the first verified stable native release, the catalog intentionally +contains only this README and its verification workflow. The workflow treats +that exact empty publication state as healthy; partial metadata is rejected. +The release train creates the formula, Scoop manifest, and cryptographic lock +together in one pull request. diff --git a/cli/__tests__/scripts/release-workflows.test.ts b/cli/__tests__/scripts/release-workflows.test.ts index 008d989..a59ed3a 100644 --- a/cli/__tests__/scripts/release-workflows.test.ts +++ b/cli/__tests__/scripts/release-workflows.test.ts @@ -189,6 +189,9 @@ describe('zero-touch release workflow contracts', () => { expect(match[2]).toMatch(/^[a-f0-9]{40}$/); } expect(source).toContain('manifest digest mismatch'); + expect(source).toContain('published=false'); + expect(source).toContain('catalog metadata is partially initialized'); + expect(source).toContain("needs.integrity.outputs.published == 'true'"); expect(source).toContain('node scripts/release/index.js catalog'); expect(source).toContain('--check'); expect(source).toContain('runs-on: macos-15'); From bd0ddece98d445c6c003fe0678223b059d55c4d8 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:44:20 -0400 Subject: [PATCH 13/23] test(cli): freeze rate-limit refill timing --- cli/__tests__/providers/decorators/rate-limited-provider.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/__tests__/providers/decorators/rate-limited-provider.test.ts b/cli/__tests__/providers/decorators/rate-limited-provider.test.ts index 4759c60..ba11ab7 100644 --- a/cli/__tests__/providers/decorators/rate-limited-provider.test.ts +++ b/cli/__tests__/providers/decorators/rate-limited-provider.test.ts @@ -364,6 +364,7 @@ describe('RateLimitedProvider – edge cases', () => { describe('costMultiplier', () => { it('should apply costMultiplier to token consumption', async () => { + useControlledTime(); const mock = new MockProvider(); // countMessagesTokens = 1000 const rateLimited = new RateLimitedProvider(mock, { maxTokens: 3000, From 7516105f5e69d7a02d67eddd3ca0cf63fe9d65da Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:45:00 -0400 Subject: [PATCH 14/23] ci: avoid duplicate release branch gates --- .github/workflows/ci.yml | 2 +- cli/__tests__/scripts/release-workflows.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8f44f5..6a9ec5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, develop, "release/**"] + branches: [main, develop] pull_request: branches: [main, develop] diff --git a/cli/__tests__/scripts/release-workflows.test.ts b/cli/__tests__/scripts/release-workflows.test.ts index a59ed3a..fda0787 100644 --- a/cli/__tests__/scripts/release-workflows.test.ts +++ b/cli/__tests__/scripts/release-workflows.test.ts @@ -25,6 +25,7 @@ describe('zero-touch release workflow contracts', () => { const source = workflowSource('ci.yml'); expect(yaml.load(source)).toBeTruthy(); expect(source).not.toMatch(/^\s+tags:/m); + expect(source).not.toContain('"release/**"'); expect(source).not.toContain('npm publish'); expect(source).not.toContain('gh release create'); expect(source).toContain('npm test -- --coverage --runInBand'); From bdbb8853ff6099f2bb839722029f379aea0ed66c Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:45:57 -0400 Subject: [PATCH 15/23] ci: cancel superseded pull request gates --- .github/workflows/ci.yml | 4 ++++ cli/__tests__/scripts/release-workflows.test.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a9ec5b..5c8c7c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: RELEASE_NODE_VERSION: 22.23.1 diff --git a/cli/__tests__/scripts/release-workflows.test.ts b/cli/__tests__/scripts/release-workflows.test.ts index fda0787..e1e6330 100644 --- a/cli/__tests__/scripts/release-workflows.test.ts +++ b/cli/__tests__/scripts/release-workflows.test.ts @@ -26,6 +26,8 @@ describe('zero-touch release workflow contracts', () => { expect(yaml.load(source)).toBeTruthy(); expect(source).not.toMatch(/^\s+tags:/m); expect(source).not.toContain('"release/**"'); + expect(source).toContain('group: ci-${{ github.event.pull_request.number || github.ref }}'); + expect(source).toContain('cancel-in-progress: true'); expect(source).not.toContain('npm publish'); expect(source).not.toContain('gh release create'); expect(source).toContain('npm test -- --coverage --runInBand'); From c787d6e468f82f7c2ee04b866bac8c7f817efe31 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 26 Jul 2026 00:47:28 -0400 Subject: [PATCH 16/23] docs(release): describe safe catalog bootstrap --- docs/RELEASE_ONBOARDING.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/RELEASE_ONBOARDING.md b/docs/RELEASE_ONBOARDING.md index 80cdae2..d97a581 100644 --- a/docs/RELEASE_ONBOARDING.md +++ b/docs/RELEASE_ONBOARDING.md @@ -48,12 +48,17 @@ caller. Environment and OIDC policies therefore identify Initialize `ntanwir10/homebrew-tap` with: ```text -Formula/guardscan.rb -bucket/guardscan.json -channel-lock.json +README.md .github/workflows/verify.yml ``` +Before the first verified stable native release, all of +`Formula/guardscan.rb`, `bucket/guardscan.json`, and `channel-lock.json` must +remain absent. The verification workflow accepts only that exact unpublished +state; it rejects partial catalog metadata. The first stable catalog PR creates +all three generated files atomically. After publication, the repository layout +contains all five files. + Protect catalog `main`; require pull requests and the catalog verification check. Stable metadata is merged only to `main`. RC metadata lives on temporary `channel-preview/vVERSION` branches. Install the release App on this repository From 9c712e7e1557d7e01ad7b631ca1568d654592ed3 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 2 Aug 2026 14:08:40 -0400 Subject: [PATCH 17/23] refactor(telemetry): remove hosted collector fallback --- ...s => telemetry-collector.contract.test.ts} | 24 ++------ cli/__tests__/core/telemetry.test.ts | 34 +++++++++++ cli/__tests__/utils/api-client.test.ts | 18 ------ cli/__tests__/utils/telemetry-client.test.ts | 56 +++++++++++++++++++ cli/src/constants/api-constants.ts | 3 - cli/src/core/telemetry.ts | 21 ++++--- .../{api-client.ts => telemetry-client.ts} | 26 +-------- 7 files changed, 110 insertions(+), 72 deletions(-) rename cli/__tests__/contracts/{monitoring-api.contract.test.ts => telemetry-collector.contract.test.ts} (67%) delete mode 100644 cli/__tests__/utils/api-client.test.ts create mode 100644 cli/__tests__/utils/telemetry-client.test.ts rename cli/src/utils/{api-client.ts => telemetry-client.ts} (91%) diff --git a/cli/__tests__/contracts/monitoring-api.contract.test.ts b/cli/__tests__/contracts/telemetry-collector.contract.test.ts similarity index 67% rename from cli/__tests__/contracts/monitoring-api.contract.test.ts rename to cli/__tests__/contracts/telemetry-collector.contract.test.ts index 1d502f7..fdb53d0 100644 --- a/cli/__tests__/contracts/monitoring-api.contract.test.ts +++ b/cli/__tests__/contracts/telemetry-collector.contract.test.ts @@ -1,18 +1,18 @@ /** - * Monitoring API contract tests. + * Telemetry collector contract tests. * * These tests pin the request paths and payload envelopes emitted by the CLI * telemetry client. */ import axios from 'axios'; -import { APIClient, TelemetryRequest } from '../../src/utils/api-client'; +import { TelemetryClient, TelemetryRequest } from '../../src/utils/telemetry-client'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; -describe('Monitoring API contracts', () => { +describe('Telemetry collector contracts', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -22,10 +22,9 @@ describe('Monitoring API contracts', () => { const post = jest.fn().mockResolvedValue({ status: 202 }); mockedAxios.create.mockReturnValue({ post, - get: jest.fn(), } as any); - const client = new APIClient('https://monitoring.example'); + const client = new TelemetryClient('https://telemetry.example'); const payload: TelemetryRequest = { schemaVersion: 'guardscan.telemetry.v1', batchId: '00000000-0000-4000-8000-000000000001', @@ -56,24 +55,11 @@ describe('Monitoring API contracts', () => { expect(mockedAxios.create).toHaveBeenCalledWith( expect.objectContaining({ - baseURL: 'https://monitoring.example', + baseURL: 'https://telemetry.example', headers: { 'Content-Type': 'application/json' }, }) ); expect(post).toHaveBeenCalledWith('/api/telemetry', payload); }); - - it('checks API health with GET /health', async () => { - const get = jest.fn().mockResolvedValue({ status: 200 }); - mockedAxios.create.mockReturnValue({ - post: jest.fn(), - get, - } as any); - - const client = new APIClient('https://monitoring.example'); - await expect(client.ping()).resolves.toBe(true); - - expect(get).toHaveBeenCalledWith('/health', { timeout: 3000 }); - }); }); }); diff --git a/cli/__tests__/core/telemetry.test.ts b/cli/__tests__/core/telemetry.test.ts index ee43f2d..4ca8f1e 100644 --- a/cli/__tests__/core/telemetry.test.ts +++ b/cli/__tests__/core/telemetry.test.ts @@ -28,6 +28,7 @@ describe('TelemetryManager', () => { let stateDir: string; let legacyCacheDir: string; const oldUrl = process.env.GUARDSCAN_TELEMETRY_URL; + const oldLegacyApiUrl = process.env.GUARDSCAN_API_URL; const oldOffline = process.env.GUARDSCAN_OFFLINE; const oldNoTelemetry = process.env.GUARDSCAN_NO_TELEMETRY; @@ -37,6 +38,7 @@ describe('TelemetryManager', () => { stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-telemetry-')); legacyCacheDir = path.join(stateDir, 'cache'); process.env.GUARDSCAN_TELEMETRY_URL = 'https://telemetry.example'; + delete process.env.GUARDSCAN_API_URL; delete process.env.GUARDSCAN_NO_TELEMETRY; delete process.env.GUARDSCAN_OFFLINE; }); @@ -45,6 +47,8 @@ describe('TelemetryManager', () => { fs.rmSync(stateDir, { recursive: true, force: true }); if (oldUrl === undefined) delete process.env.GUARDSCAN_TELEMETRY_URL; else process.env.GUARDSCAN_TELEMETRY_URL = oldUrl; + if (oldLegacyApiUrl === undefined) delete process.env.GUARDSCAN_API_URL; + else process.env.GUARDSCAN_API_URL = oldLegacyApiUrl; if (oldOffline === undefined) delete process.env.GUARDSCAN_OFFLINE; else process.env.GUARDSCAN_OFFLINE = oldOffline; if (oldNoTelemetry === undefined) delete process.env.GUARDSCAN_NO_TELEMETRY; @@ -89,6 +93,36 @@ describe('TelemetryManager', () => { await expect(manager.sync()).rejects.toThrow('disabled for this command'); }); + it('rejects the removed legacy endpoint alias without creating a network transport', async () => { + delete process.env.GUARDSCAN_TELEMETRY_URL; + process.env.GUARDSCAN_API_URL = 'https://legacy.example.test'; + const manager = new TelemetryManager(config(), stateDir); + + expect(manager.getStats().endpointConfigured).toBe(false); + await expect(manager.sync()).rejects.toThrow( + 'Set GUARDSCAN_TELEMETRY_URL to your self-hosted collector URL before syncing.' + ); + expect(mockedAxios.create).not.toHaveBeenCalled(); + }); + + it('uses only the explicit telemetry endpoint when the removed alias is also present', async () => { + process.env.GUARDSCAN_API_URL = 'https://legacy.example.test'; + const post = jest.fn().mockImplementation((_url, request) => Promise.resolve({ + status: 202, + data: { status: 'accepted', batchId: request.batchId, accepted: request.events.length }, + })); + mockedAxios.create.mockReturnValue({ post } as any); + const manager = new TelemetryManager(config(), stateDir); + await manager.record({ action: 'scan', loc: 1, durationMs: 2 }); + + await expect(manager.sync()).resolves.toEqual({ sent: 1, remaining: 0 }); + + expect(mockedAxios.create).toHaveBeenCalledWith(expect.objectContaining({ + baseURL: 'https://telemetry.example', + })); + expect(post).toHaveBeenCalledTimes(1); + }); + it('does not lose events recorded by concurrent managers', async () => { const managers = Array.from({ length: 25 }, () => new TelemetryManager(config(), stateDir)); await Promise.all(managers.map((manager, index) => manager.record({ diff --git a/cli/__tests__/utils/api-client.test.ts b/cli/__tests__/utils/api-client.test.ts deleted file mode 100644 index 39ff5d0..0000000 --- a/cli/__tests__/utils/api-client.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import axios from 'axios'; -import { APIClient } from '../../src/utils/api-client'; - -jest.mock('axios', () => ({ - __esModule: true, - default: { - create: jest.fn(() => ({post: jest.fn()})), - isAxiosError: jest.fn(() => false), - }, -})); - -describe('telemetry API transport policy', () => { - it('disables HTTP redirects so telemetry cannot be forwarded to another origin', () => { - new APIClient('https://telemetry.example.test'); - - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({maxRedirects: 0})); - }); -}); diff --git a/cli/__tests__/utils/telemetry-client.test.ts b/cli/__tests__/utils/telemetry-client.test.ts new file mode 100644 index 0000000..e39e4ab --- /dev/null +++ b/cli/__tests__/utils/telemetry-client.test.ts @@ -0,0 +1,56 @@ +import axios from 'axios'; +import { TelemetryClient } from '../../src/utils/telemetry-client'; + +jest.mock('axios', () => ({ + __esModule: true, + default: { + create: jest.fn(() => ({post: jest.fn()})), + isAxiosError: jest.fn(() => false), + }, +})); + +describe('telemetry transport policy', () => { + const originalTelemetryUrl = process.env.GUARDSCAN_TELEMETRY_URL; + const originalLegacyApiUrl = process.env.GUARDSCAN_API_URL; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.GUARDSCAN_TELEMETRY_URL; + delete process.env.GUARDSCAN_API_URL; + }); + + afterAll(() => { + if (originalTelemetryUrl === undefined) delete process.env.GUARDSCAN_TELEMETRY_URL; + else process.env.GUARDSCAN_TELEMETRY_URL = originalTelemetryUrl; + if (originalLegacyApiUrl === undefined) delete process.env.GUARDSCAN_API_URL; + else process.env.GUARDSCAN_API_URL = originalLegacyApiUrl; + }); + + it('disables HTTP redirects so telemetry cannot be forwarded to another origin', () => { + new TelemetryClient('https://telemetry.example.test'); + + expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({maxRedirects: 0})); + }); + + it('does not create a transport from the removed GUARDSCAN_API_URL alias', () => { + process.env.GUARDSCAN_API_URL = 'https://legacy.example.test'; + + const client = new TelemetryClient(''); + + expect(client.getBaseUrl()).toBe(''); + expect(axios.create).not.toHaveBeenCalled(); + }); + + it('uses the caller-provided endpoint regardless of legacy process state', () => { + process.env.GUARDSCAN_TELEMETRY_URL = 'https://telemetry.example.test'; + process.env.GUARDSCAN_API_URL = 'https://legacy.example.test'; + + const client = new TelemetryClient('https://telemetry.example.test'); + + expect(client.getBaseUrl()).toBe('https://telemetry.example.test'); + expect(axios.create).toHaveBeenCalledTimes(1); + expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ + baseURL: 'https://telemetry.example.test', + })); + }); +}); diff --git a/cli/src/constants/api-constants.ts b/cli/src/constants/api-constants.ts index 37bb5ab..5810c04 100644 --- a/cli/src/constants/api-constants.ts +++ b/cli/src/constants/api-constants.ts @@ -4,8 +4,5 @@ export const API_CONSTANTS = { // Use npm registry instead of GitHub releases for version checking // This ensures we check against what's actually published and available to users VERSION_CHECK_URL: "https://registry.npmjs.org/guardscan/latest", - // Legacy API default used by explicit telemetry and health clients. - // Override with GUARDSCAN_TELEMETRY_URL for a self-hosted collector. - DEFAULT_API_BASE_URL: "https://api.guardscancli.com", VERSION_CACHE_HOURS: 24, } as const; diff --git a/cli/src/core/telemetry.ts b/cli/src/core/telemetry.ts index bb76de4..a78ded3 100644 --- a/cli/src/core/telemetry.ts +++ b/cli/src/core/telemetry.ts @@ -3,11 +3,11 @@ import * as path from "path"; import * as crypto from "crypto"; import { v4 as uuidv4 } from "uuid"; import { - APIClient, + TelemetryClient, TelemetryAction, TelemetryEvent, TelemetryExecutionMode, -} from "../utils/api-client"; +} from "../utils/telemetry-client"; import { Config, configManager } from "./config"; import { TELEMETRY_CONSTANTS } from "../constants/telemetry-constants"; import { createDebugLogger } from "../utils/debug-logger"; @@ -147,11 +147,11 @@ export class TelemetryManager { async sync(): Promise<{ sent: number; remaining: number }> { this.assertSyncAllowed(); - const endpoint = - process.env.GUARDSCAN_TELEMETRY_URL || process.env.GUARDSCAN_API_URL; + const endpoint = configuredTelemetryEndpoint(); if (!endpoint) { throw new Error( - "Telemetry endpoint is not configured. Set GUARDSCAN_TELEMETRY_URL." + "Telemetry endpoint is not configured. " + + "Set GUARDSCAN_TELEMETRY_URL to your self-hosted collector URL before syncing." ); } @@ -169,7 +169,7 @@ export class TelemetryManager { if (events.length === 0) {return { sent: 0, remaining: 0 };} const batchId = uuidv4(); - const response = await new APIClient(endpoint).sendTelemetry({ + const response = await new TelemetryClient(endpoint).sendTelemetry({ schemaVersion: "guardscan.telemetry.v1", batchId, sentAt: Date.now(), @@ -225,9 +225,7 @@ export class TelemetryManager { return { enabled: this.config.telemetryEnabled, suppressed: isTelemetrySuppressed(this.config), - endpointConfigured: Boolean( - process.env.GUARDSCAN_TELEMETRY_URL || process.env.GUARDSCAN_API_URL - ), + endpointConfigured: configuredTelemetryEndpoint() !== undefined, pending, oldestEventAt: oldestEventAt === undefined ? undefined @@ -520,6 +518,11 @@ export class TelemetryManager { } +function configuredTelemetryEndpoint(): string | undefined { + const endpoint = process.env.GUARDSCAN_TELEMETRY_URL?.trim(); + return endpoint || undefined; +} + export function createTelemetryManager( config: Config, stateDir = configManager.getConfigDir(), diff --git a/cli/src/utils/api-client.ts b/cli/src/utils/telemetry-client.ts similarity index 91% rename from cli/src/utils/api-client.ts rename to cli/src/utils/telemetry-client.ts index d086dbe..a7c84d5 100644 --- a/cli/src/utils/api-client.ts +++ b/cli/src/utils/telemetry-client.ts @@ -43,17 +43,12 @@ export class TelemetryDeliveryError extends Error { } } -export class APIClient { +export class TelemetryClient { private client?: AxiosInstance; private baseUrl: string; - constructor(baseUrl?: string) { - this.baseUrl = ( - baseUrl || - process.env.GUARDSCAN_TELEMETRY_URL || - process.env.GUARDSCAN_API_URL || - "" - ).replace(/\/+$/, ""); + constructor(baseUrl: string) { + this.baseUrl = baseUrl.trim().replace(/\/+$/, ""); if (this.baseUrl) { this.validateBaseUrl(this.baseUrl); @@ -178,18 +173,6 @@ export class APIClient { } } - async ping(): Promise { - if (!this.client) { - return false; - } - try { - await this.client.get("/health", { timeout: 3000 }); - return true; - } catch { - return false; - } - } - getBaseUrl(): string { return this.baseUrl; } @@ -226,6 +209,3 @@ export class APIClient { } } } - -/** @deprecated Construct APIClient with the explicit telemetry URL instead. */ -export const apiClient = new APIClient(); From 85eae9b3e643ceaa12245a742f9c869de8afffbe Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 2 Aug 2026 14:08:49 -0400 Subject: [PATCH 18/23] build(release): reject retired runtime endpoints --- .../scripts/standalone-builder.test.ts | 49 ++++++++++++++++++ cli/scripts/package-smoke.js | 2 + .../release/runtime-artifact-policy.js | 51 +++++++++++++++++++ cli/scripts/release/standalone.js | 3 ++ 4 files changed, 105 insertions(+) create mode 100644 cli/scripts/release/runtime-artifact-policy.js diff --git a/cli/__tests__/scripts/standalone-builder.test.ts b/cli/__tests__/scripts/standalone-builder.test.ts index fcb1797..a45af5d 100644 --- a/cli/__tests__/scripts/standalone-builder.test.ts +++ b/cli/__tests__/scripts/standalone-builder.test.ts @@ -1,3 +1,5 @@ +import fs from 'fs'; +import os from 'os'; import path from 'path'; const { @@ -26,6 +28,16 @@ const { ) => Promise; }; +const { + FORBIDDEN_RUNTIME_LITERALS, + assertCompiledRuntimeFilesClean, + assertRuntimeArtifactClean, +} = require('../../scripts/release/runtime-artifact-policy') as { + FORBIDDEN_RUNTIME_LITERALS: readonly string[]; + assertCompiledRuntimeFilesClean: (root: string, files: Iterable, label?: string) => string[]; + assertRuntimeArtifactClean: (content: Buffer | string, label: string) => void; +}; + function metafile(imports: Array<{path: string; external?: boolean}>): Record { return { outputs: { @@ -35,6 +47,43 @@ function metafile(imports: Array<{path: string; external?: boolean}>): Record { + it('fails closed on retired API literals in runtime bytes', () => { + expect(FORBIDDEN_RUNTIME_LITERALS).toEqual([ + 'api.guardscancli.com', + 'GUARDSCAN_API_URL', + 'DEFAULT_API_BASE_URL', + ]); + expect(() => assertRuntimeArtifactClean(Buffer.from('clean runtime'), 'SEA payload')).not.toThrow(); + for (const literal of FORBIDDEN_RUNTIME_LITERALS) { + expect(() => assertRuntimeArtifactClean( + Buffer.from(`runtime contains ${literal}`), + 'SEA payload' + )).toThrow(`SEA payload contains forbidden retired runtime literals: ${literal}`); + } + }); + + it('scans packed compiled JavaScript without rejecting packaged historical documentation', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-runtime-policy-')); + try { + fs.mkdirSync(path.join(root, 'dist')); + fs.writeFileSync(path.join(root, 'dist', 'index.js'), 'module.exports = true;\n'); + fs.writeFileSync(path.join(root, 'README.md'), FORBIDDEN_RUNTIME_LITERALS.join('\n')); + fs.writeFileSync(path.join(root, 'CHANGELOG.md'), FORBIDDEN_RUNTIME_LITERALS.join('\n')); + const files = ['README.md', 'CHANGELOG.md', 'dist/index.js']; + + expect(() => assertCompiledRuntimeFilesClean(root, files.slice(0, 2), 'npm packed runtime')) + .toThrow('npm packed runtime contains no compiled JavaScript under dist/'); + expect(assertCompiledRuntimeFilesClean(root, files, 'npm packed runtime')) + .toEqual(['dist/index.js']); + + fs.writeFileSync(path.join(root, 'dist', 'index.js'), 'const url = "api.guardscancli.com";\n'); + expect(() => assertCompiledRuntimeFilesClean(root, files, 'npm packed runtime')) + .toThrow(/npm packed runtime dist\/index\.js contains forbidden retired runtime literals/); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + it('creates a single CommonJS Node 22 bundle and externalizes only optional native capabilities', () => { const options = bundleOptions('/source/dist/index.js', '/output/guardscan.bundle.cjs'); expect(options).toMatchObject({ diff --git a/cli/scripts/package-smoke.js b/cli/scripts/package-smoke.js index 6b1b6e4..f690088 100644 --- a/cli/scripts/package-smoke.js +++ b/cli/scripts/package-smoke.js @@ -7,6 +7,7 @@ const path = require('path'); const { spawnSync } = require('child_process'); const Ajv = require('ajv'); const addFormats = require('ajv-formats'); +const {assertCompiledRuntimeFilesClean} = require('./release/runtime-artifact-policy'); const packageRoot = path.resolve(__dirname, '..'); const expectedVersion = require(path.join(packageRoot, 'package.json')).version; @@ -46,6 +47,7 @@ try { ? path.join(globalPrefix, 'node_modules', 'guardscan') : path.join(globalPrefix, 'lib', 'node_modules', 'guardscan'); const files = new Set(listPackageFiles(installedPackage)); + assertCompiledRuntimeFilesClean(installedPackage, files, 'npm packed runtime'); for (const required of [ 'package.json', 'dist/index.js', diff --git a/cli/scripts/release/runtime-artifact-policy.js b/cli/scripts/release/runtime-artifact-policy.js new file mode 100644 index 0000000..87ae790 --- /dev/null +++ b/cli/scripts/release/runtime-artifact-policy.js @@ -0,0 +1,51 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const FORBIDDEN_RUNTIME_LITERALS = Object.freeze([ + 'api.guardscancli.com', + 'GUARDSCAN_API_URL', + 'DEFAULT_API_BASE_URL', +]); + +function findForbiddenRuntimeLiterals(content) { + const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8'); + return FORBIDDEN_RUNTIME_LITERALS.filter(literal => bytes.includes(Buffer.from(literal, 'utf8'))); +} + +function assertRuntimeArtifactClean(content, label) { + const matches = findForbiddenRuntimeLiterals(content); + if (matches.length > 0) { + throw new Error(`${label} contains forbidden retired runtime literals: ${matches.join(', ')}`); + } +} + +function assertCompiledRuntimeFilesClean(root, files, label = 'compiled runtime') { + const resolvedRoot = path.resolve(root); + const runtimeFiles = [...files] + .map(file => String(file).replace(/\\/g, '/')) + .filter(file => file.startsWith('dist/') && /\.(?:c|m)?js$/i.test(file)) + .sort(); + if (runtimeFiles.length === 0) { + throw new Error(`${label} contains no compiled JavaScript under dist/`); + } + for (const file of runtimeFiles) { + const absolute = path.resolve(resolvedRoot, file); + const relative = path.relative(resolvedRoot, absolute); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`${label} contains an unsafe runtime path: ${file}`); + } + const stat = fs.lstatSync(absolute); + if (!stat.isFile()) throw new Error(`${label} is not a regular file: ${file}`); + assertRuntimeArtifactClean(fs.readFileSync(absolute), `${label} ${file}`); + } + return runtimeFiles; +} + +module.exports = { + FORBIDDEN_RUNTIME_LITERALS, + assertCompiledRuntimeFilesClean, + assertRuntimeArtifactClean, + findForbiddenRuntimeLiterals, +}; diff --git a/cli/scripts/release/standalone.js b/cli/scripts/release/standalone.js index 8edc5a6..e0a6682 100644 --- a/cli/scripts/release/standalone.js +++ b/cli/scripts/release/standalone.js @@ -8,6 +8,7 @@ const {spawnSync} = require('child_process'); const {builtinModules} = require('module'); const esbuild = require('esbuild'); const {inject} = require('postject'); +const {assertRuntimeArtifactClean} = require('./runtime-artifact-policy'); const PROTOTYPE_SCHEMA = 'guardscan.standalone-prototype.v1'; const SEA_FUSE = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; @@ -238,6 +239,7 @@ async function prepareExecutable(bundleFile, blobFile, executable) { useCodeCache: false, }, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); run(process.execPath, ['--experimental-sea-config', seaConfig], path.dirname(bundleFile), process.env); + assertRuntimeArtifactClean(fs.readFileSync(blobFile), 'standalone SEA payload'); fs.copyFileSync(process.execPath, executable); fs.chmodSync(executable, 0o755); if (process.platform === 'darwin') { @@ -273,6 +275,7 @@ async function buildHostPrototype(source, outputDir) { const executable = path.join(stage, executableName); const build = await esbuild.build(bundleOptions(entryPoint, bundleFile)); const externals = assertExternalAllowlist(build.metafile); + assertRuntimeArtifactClean(fs.readFileSync(bundleFile), 'standalone bundle'); const bundle = hashRegularFile(bundleFile, MAX_BUNDLE_BYTES, 'standalone bundle'); await prepareExecutable(bundleFile, blobFile, executable); const smoke = smokeStandalone(executable, source.version); From d94a07a5cf9c2bf06723cdcbb3ef80f3d6982636 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 2 Aug 2026 14:08:59 -0400 Subject: [PATCH 19/23] docs: retire hosted Cloudflare telemetry --- .gitignore | 1 - PRIVACY.md | 28 +++++- README.md | 16 ++-- SECURITY.md | 5 +- cli/CHANGELOG.md | 13 +++ cli/README.md | 16 ++-- cli/docs/DEBUGGING.md | 9 +- docs/AI_QUICK_REFERENCE.md | 7 +- docs/API.md | 23 ++++- docs/DOCKER_GUIDE.md | 11 ++- docs/GETTING_STARTED.md | 15 ++- docs/RATE_LIMITING.md | 9 +- docs/adrs/001-cloudflare-workers-backend.md | 23 +++-- docs/adrs/003-privacy-first-architecture.md | 17 +++- docs/adrs/005-byok-ai-model.md | 2 +- .../007-retire-hosted-cloudflare-telemetry.md | 95 +++++++++++++++++++ docs/adrs/README.md | 16 ++-- 17 files changed, 247 insertions(+), 59 deletions(-) create mode 100644 docs/adrs/007-retire-hosted-cloudflare-telemetry.md diff --git a/.gitignore b/.gitignore index b242cae..bb27287 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,6 @@ docs/AI_ARCHITECTURE.md docs/AI_FEATURES_MASTER_PLAN.md docs/AI_IMPLEMENTATION_DECISIONS.md docs/PHASE_4_5_IMPLEMENTATION_PLAN.md -docs/CLOUDFLARE_DOMAIN_SETUP.md docs/database-schema.md docs/deployment.md docs/API.md diff --git a/PRIVACY.md b/PRIVACY.md index 2f471d2..49e4ec6 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,8 +1,11 @@ # Privacy Policy -**Last updated: July 20, 2026** +**Last updated: August 2, 2026** -GuardScan is a local-first security scanner and BYOK AI client. This document describes the CLI's network boundaries and the data it stores. It distinguishes local scanning, third-party AI providers, public vulnerability services, and optional GuardScan telemetry because they have different privacy properties. +GuardScan is a local-first security scanner and BYOK AI client. This document +describes the CLI's network boundaries and the data it stores. It distinguishes +local scanning, third-party AI providers, public vulnerability services, and +optional self-hosted telemetry because they have different privacy properties. ## Defaults @@ -64,7 +67,16 @@ See [Vulnerability Scanning](./docs/VULNERABILITY_SCANNING.md) for coverage and ## Telemetry -Telemetry is opt-in and never uploads automatically. Enabling consent allows GuardScan to queue an allowlisted event locally; delivery happens only when you run `guardscan telemetry sync`. +Telemetry is opt-in and never uploads automatically. Enabling consent allows +GuardScan to queue an allowlisted event locally; delivery happens only when you +configure a user-operated collector with `GUARDSCAN_TELEMETRY_URL` and run +`guardscan telemetry sync`. + +GuardScan does not operate a hosted telemetry collector. The former Cloudflare +service at `api.guardscancli.com` is retired under +[ADR 007](./docs/adrs/007-retire-hosted-cloudflare-telemetry.md). It is not a +valid default or self-hosting target. The apex `guardscancli.com` website and +document identifiers remain active and are separate from that retired service. An event may contain: @@ -108,7 +120,15 @@ guardscan telemetry status guardscan telemetry clear --force ``` -No hosted telemetry endpoint is configured by default. Whoever operates the endpoint is responsible for publishing its retention, deletion, access-control, and jurisdiction terms. +There is no GuardScan-hosted endpoint or default. Whoever operates the +self-hosted endpoint is responsible for publishing its retention, deletion, +access-control, and jurisdiction terms. + +After `1.1.0` is published, the retired endpoint returns `410 Gone` for seven +days without accepting, storing, or redirecting telemetry, and is then deleted. +Users of `1.0.5` and earlier should upgrade or disable telemetry. Existing local +queues are not transferred and remain available to inspect, explicitly sync to +a user-operated collector, or delete. ## Local storage diff --git a/README.md b/README.md index aede9d4..646fae0 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ OS-backed network sandbox and fails the affected checks if the platform sandbox We take privacy seriously: -### ❌ Never Sent to GuardScan Servers +### ❌ No GuardScan-Hosted Processing - Your source code - File paths or file names @@ -255,8 +255,11 @@ Local AI/RAG features can store prompts, responses, file paths, and code-derived - Disabled by default and explicitly enabled with `guardscan config --telemetry=true` - Queued locally only after consent -- Sent only by `guardscan telemetry sync` to an endpoint you configure +- Sent only by `guardscan telemetry sync` to a user-operated endpoint selected + with `GUARDSCAN_TELEMETRY_URL` - Recording and delivery are suppressed by offline mode or `--no-telemetry` +- Never sent to a GuardScan-hosted collector; GuardScan operates no telemetry + ingestion service See the [Privacy Policy](./PRIVACY.md) for the exact event allowlist and local retention behavior. @@ -347,7 +350,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a β”‚ β”‚ β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ User's AI Provider β”‚ β”‚ Configured HTTPS β”‚ +β”‚ User's AI Provider β”‚ β”‚ User-operated HTTPS β”‚ β”‚ (User pays directly) β”‚ β”‚ telemetry endpoint β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β€’ OpenAI β”‚ β”‚ β€’ Explicit sync only β”‚ @@ -369,10 +372,11 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a - Testing: Jest with enforced coverage thresholds - Build: TypeScript Compiler (tsc) -**Telemetry collector (optional):** +**Self-hosted telemetry collector (optional):** -- The CLI has no hosted endpoint configured by default. -- Operators can deploy any compatible HTTPS collector. +- GuardScan does not operate a hosted telemetry collector or provide a default + endpoint. +- Operators may deploy their own compatible HTTPS collector. - Delivery occurs only through `guardscan telemetry sync` after setting `GUARDSCAN_TELEMETRY_URL`. - The payload is the strict aggregate event allowlist described in [PRIVACY.md](./PRIVACY.md); errors and findings are excluded. diff --git a/SECURITY.md b/SECURITY.md index 2f37f15..37c3865 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -79,7 +79,7 @@ We follow responsible disclosure practices: - GuardScan works offline for static analysis - Cloud AI and fresh CVE lookups require network access; local AI is offline-only at literal loopback IP endpoints - Use `--offline` to block GuardScan cloud, advisory, update, and telemetry clients - - Telemetry is opt-in and delivered only by an explicit `guardscan telemetry sync` + - Telemetry is opt-in and delivered only by an explicit `guardscan telemetry sync` to a user-operated endpoint selected with `GUARDSCAN_TELEMETRY_URL` ### For Developers @@ -130,7 +130,8 @@ If telemetry is enabled: - Only action, aggregate LOC, duration, coarse execution mode, event ID, and timestamp are queued - Source, paths, prompts, responses, findings, dependency names, and errors are excluded -- Events remain local until `guardscan telemetry sync` is run against a configured HTTPS endpoint +- GuardScan operates no hosted telemetry collector and provides no default endpoint +- Events remain local until `guardscan telemetry sync` is run against a user-operated HTTPS endpoint selected with `GUARDSCAN_TELEMETRY_URL` - You can suppress an invocation with `--no-telemetry` and inspect or clear the queue with `guardscan telemetry` ### Dependency Scanning diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index eb28d21..368ef62 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -22,9 +22,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `guardscan run` now always executes required local scanners before optional AI enrichment and records incomplete coverage as operational exit code `2`. - Offline Ollama and LM Studio endpoints now require literal loopback IPs. Remote self-hosted endpoints require online mode and `allowRemoteSelfHosted: true`. - Telemetry and metrics retention, clearing, status, and migration now process the complete state directory with journaled rollback. +- Self-hosted telemetry delivery now requires an explicit + `GUARDSCAN_TELEMETRY_URL`; consent alone only queues allowlisted events + locally until the user runs `guardscan telemetry sync`. - Configuration parsing is size-, depth-, node-, alias-, and schema-bounded; read-modify-write updates are lease-serialized across processes. - The supported Node.js runtime floor is now 22, with package smoke coverage on Node 22 and 24 across Linux, macOS, and Windows. +### Removed + +- The GuardScan-hosted Cloudflare telemetry service at + `api.guardscancli.com`, its implicit default endpoint, and the legacy + `GUARDSCAN_API_URL` setting. After `1.1.0` is published, the retired endpoint + returns `410 Gone` without accepting, storing, or redirecting data for seven + days and is then deleted. Users of `1.0.5` and earlier should upgrade or run + `guardscan config --telemetry=false`; self-hosted operators must use + `GUARDSCAN_TELEMETRY_URL`. Existing queues remain local. + ### Fixed - Telemetry clearing and retention leaving events stranded beyond the former directory scan limit. diff --git a/cli/README.md b/cli/README.md index 4de9def..6da7584 100644 --- a/cli/README.md +++ b/cli/README.md @@ -206,7 +206,7 @@ OS-backed network sandbox and fails the affected checks if the platform sandbox We take privacy seriously: -### ❌ Never Sent to GuardScan Servers +### ❌ No GuardScan-Hosted Processing - Your source code - File paths or file names @@ -227,8 +227,11 @@ Local AI/RAG features can store prompts, responses, file paths, and code-derived - Disabled by default and explicitly enabled with `guardscan config --telemetry=true` - Queued locally only after consent -- Sent only by `guardscan telemetry sync` to an endpoint you configure +- Sent only by `guardscan telemetry sync` to a user-operated endpoint selected + with `GUARDSCAN_TELEMETRY_URL` - Recording and delivery are suppressed by offline mode or `--no-telemetry` +- Never sent to a GuardScan-hosted collector; GuardScan operates no telemetry + ingestion service - Strictly allowlisted; malformed or unknown persisted fields are quarantined locally and never sent - Retained through bounded maintenance (newest 1,000 events, 30 days, 20 quarantine artifacts) - Serialized across processes during explicit synchronization @@ -322,7 +325,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a β”‚ β”‚ β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ User's AI Provider β”‚ β”‚ Configured HTTPS β”‚ +β”‚ User's AI Provider β”‚ β”‚ User-operated HTTPS β”‚ β”‚ (User pays directly) β”‚ β”‚ telemetry endpoint β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β€’ OpenAI β”‚ β”‚ β€’ Explicit sync only β”‚ @@ -344,10 +347,11 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a - Testing: Jest with enforced coverage thresholds - Build: TypeScript Compiler (tsc) -**Telemetry collector (optional):** +**Self-hosted telemetry collector (optional):** -- The CLI has no hosted endpoint configured by default. -- Operators can deploy any compatible HTTPS collector. +- GuardScan does not operate a hosted telemetry collector or provide a default + endpoint. +- Operators may deploy their own compatible HTTPS collector. - Delivery occurs only through `guardscan telemetry sync` after setting `GUARDSCAN_TELEMETRY_URL`. - The payload is the strict aggregate event allowlist described in [PRIVACY.md](../PRIVACY.md); errors and findings are excluded. diff --git a/cli/docs/DEBUGGING.md b/cli/docs/DEBUGGING.md index 6670ac7..a663003 100644 --- a/cli/docs/DEBUGGING.md +++ b/cli/docs/DEBUGGING.md @@ -150,9 +150,12 @@ Look for: - Branch detection - Repo ID generation -### 5. Backend API Issues +### 5. Self-Hosted Telemetry Collector Issues -For backend debugging, check Cloudflare Workers logs. Debug logging is automatically enabled in development mode. +GuardScan operates no hosted telemetry backend. If explicit telemetry sync +fails, verify `GUARDSCAN_TELEMETRY_URL`, inspect the logs and policies of the +user-operated collector, and run `guardscan telemetry status`. GuardScan debug +logging is automatically enabled in development mode. ## JSON Output for CI @@ -260,6 +263,6 @@ If debug logging doesn't help resolve your issue: For more information, see: -- [Testing Guide](./TESTING.md) +- [Testing Tools Guide](./TESTING_TOOLS.md) - [Performance Guide](./PERFORMANCE.md) - [Main README](../README.md) diff --git a/docs/AI_QUICK_REFERENCE.md b/docs/AI_QUICK_REFERENCE.md index 282af3f..d280f6d 100644 --- a/docs/AI_QUICK_REFERENCE.md +++ b/docs/AI_QUICK_REFERENCE.md @@ -67,7 +67,10 @@ guardscan --no-telemetry security --ci --format sarif --output guardscan.sarif - - `--no-cache` disables AI response caching for one command. - `guardscan config --telemetry=false` persists telemetry opt-out and deletes queued events. - `guardscan config --offline=true` blocks cloud AI, cloud embeddings, advisory lookups, update checks, and telemetry recording and delivery. -- `guardscan telemetry status` shows consent and local queue state; `guardscan telemetry sync` is the only delivery action. +- `guardscan telemetry status` shows consent and local queue state; `guardscan telemetry sync` is the only delivery action and requires a user-operated collector selected with `GUARDSCAN_TELEMETRY_URL`. - `guardscan cache clear --repo --force` clears the current repository, while `--all` clears every repository cache. -Telemetry is disabled by default. If enabled, it queues an aggregate allowlisted event locally while online; it never includes source, paths, prompts, responses, findings, or errors. +Telemetry is disabled by default. If enabled, it queues an aggregate allowlisted +event locally while online; it never includes source, paths, prompts, responses, +findings, or errors. GuardScan operates no hosted telemetry collector or default +endpoint. diff --git a/docs/API.md b/docs/API.md index 9bb0b43..3bfb9c9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,17 +1,30 @@ -# API Documentation +# Self-Hosted Telemetry Collector Protocol and CLI Output -GuardScan is local-first and BYOK. Built-in scanners do not send source code to GuardScan services. Cloud AI requests go directly to the provider selected by the user. +GuardScan is local-first and BYOK. Built-in scanners do not send source code to +GuardScan services. Cloud AI requests go directly to the provider selected by +the user. -## Optional telemetry +## Optional self-hosted telemetry -Telemetry is anonymous, opt-in, and delivered only by an explicit `guardscan telemetry sync`. Recording and delivery are suppressed when telemetry consent is disabled, persistent offline mode is enabled, `GUARDSCAN_OFFLINE=true`, `GUARDSCAN_NO_TELEMETRY=true`, `--offline`, or `--no-telemetry` applies. +GuardScan operates no hosted telemetry collector and provides no default +endpoint. The former service at `api.guardscancli.com` and the legacy +`GUARDSCAN_API_URL` setting are retired. Operators may implement this protocol +at a user-controlled HTTPS endpoint selected with `GUARDSCAN_TELEMETRY_URL`. -### Endpoint +Telemetry is anonymous, opt-in, and delivered only by an explicit +`guardscan telemetry sync`. Recording and delivery are suppressed when +telemetry consent is disabled, persistent offline mode is enabled, +`GUARDSCAN_OFFLINE=true`, `GUARDSCAN_NO_TELEMETRY=true`, `--offline`, or +`--no-telemetry` applies. Events otherwise remain in the local queue. + +### Collector endpoint ```text POST /api/telemetry ``` +This is a relative self-hosted protocol path, not a GuardScan-operated URL. + ### Request: `guardscan.telemetry.v1` ```json diff --git a/docs/DOCKER_GUIDE.md b/docs/DOCKER_GUIDE.md index a98cb9f..129aca2 100644 --- a/docs/DOCKER_GUIDE.md +++ b/docs/DOCKER_GUIDE.md @@ -916,14 +916,17 @@ guardscan init # Will show detailed logging docker run -e GUARDSCAN_DEBUG=true node:lts-alpine guardscan init ``` -#### `GUARDSCAN_API_URL` +#### `GUARDSCAN_TELEMETRY_URL` -Override the default backend API URL (for self-hosting or testing). +Select a user-operated telemetry collector. GuardScan has no hosted or default +collector, and telemetry is sent only by an explicit `guardscan telemetry sync`. ```bash -export GUARDSCAN_API_URL=https://custom-api.example.com +export GUARDSCAN_TELEMETRY_URL=https://telemetry.example.com ``` +`GUARDSCAN_API_URL` and `api.guardscancli.com` are retired. + #### `GUARDSCAN_NO_TELEMETRY` Disable telemetry for the current command execution with either the environment @@ -2192,7 +2195,7 @@ spec: - [Main README](../README.md) - [Getting Started Guide](GETTING_STARTED.md) -- [Alpine Linux Quick Reference](../DOCKER_ALPINE_GUIDE.md) +- [Alpine Linux Quick Reference](./DOCKER_ALPINE_GUIDE.md) - [Issue #25 - Alpine Linux Fix](https://github.com/ntanwir10/GuardScan/issues/25) - [Docker Documentation](https://docs.docker.com/) - [Docker Compose Documentation](https://docs.docker.com/compose/) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 9f9fbb1..7373465 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -363,7 +363,20 @@ Or edit config: telemetryEnabled: false ``` -Telemetry is disabled by default and never uploads automatically. After explicit consent, events are queued only while online. Configure an HTTPS endpoint and run `guardscan telemetry sync` to deliver a batch; failed delivery leaves it queued. `guardscan config --telemetry=false` deletes the queue. Use `guardscan telemetry status` and `guardscan telemetry clear --force` to inspect or delete it explicitly. +Telemetry is disabled by default and never uploads automatically. GuardScan +operates no hosted collector or default endpoint. After explicit consent, +events are queued only while online and remain local until you configure a +user-operated HTTPS collector and sync explicitly: + +```bash +export GUARDSCAN_TELEMETRY_URL=https://telemetry.example.com +guardscan telemetry sync +``` + +Failed delivery leaves the batch queued. `guardscan config --telemetry=false` +deletes the queue. Use `guardscan telemetry status` and +`guardscan telemetry clear --force` to inspect or delete it explicitly. +`GUARDSCAN_API_URL` and the former `api.guardscancli.com` service are retired. ## Troubleshooting diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md index a9d9c34..ea78c01 100644 --- a/docs/RATE_LIMITING.md +++ b/docs/RATE_LIMITING.md @@ -1,6 +1,10 @@ # Telemetry Collector Rate Limiting -GuardScan does not ship or configure a hosted collector. `guardscan telemetry sync` sends one bounded batch to the HTTPS endpoint selected with `GUARDSCAN_TELEMETRY_URL`. +GuardScan does not ship, configure, or operate a hosted collector. +`guardscan telemetry sync` sends one bounded batch to the user-operated HTTPS +endpoint selected with `GUARDSCAN_TELEMETRY_URL`. The former +`api.guardscancli.com` endpoint and `GUARDSCAN_API_URL` setting are retired and +must not be used as collector configuration. The CLI sends no client or repository identifier. A collector that applies rate limits must therefore use transport-level information such as source IP, an operator-provided authentication mechanism, or aggregate endpoint limits. Collector operators must document their limits, retention, deletion, access-control, and jurisdiction policies independently of GuardScan. @@ -28,4 +32,5 @@ guardscan telemetry clear --force - Return accepted event IDs for partial acknowledgements, and return `duplicate` only for a complete duplicate batch. - Do not infer that an anonymous event identifies a stable installation or repository. -See [API Documentation](./API.md) for the exact request and acknowledgement contract. +See [Self-Hosted Telemetry Collector Protocol](./API.md) for the exact request +and acknowledgement contract. diff --git a/docs/adrs/001-cloudflare-workers-backend.md b/docs/adrs/001-cloudflare-workers-backend.md index db28225..1346e7c 100644 --- a/docs/adrs/001-cloudflare-workers-backend.md +++ b/docs/adrs/001-cloudflare-workers-backend.md @@ -1,15 +1,18 @@ # ADR 001: Cloudflare Workers for Backend Infrastructure ## Status -Superseded for the CLI by [ADR 003](./003-privacy-first-architecture.md); retained as backend history. + +Superseded by +[ADR 007](./007-retire-hosted-cloudflare-telemetry.md); retained as historical +context for the retired backend. ## Date 2024-11-19 -> **Note (2026-05):** The Worker implementation has moved out of this -> repository. This ADR captures the original platform decision only. The current -> CLI has no monitoring integration and supports anonymous, explicit telemetry -> sync only through `GUARDSCAN_TELEMETRY_URL`. +> **Retirement note (2026-08):** This document records the original platform +> decision; it is not an active deployment or product contract. GuardScan no +> longer operates hosted telemetry. Self-hosted synchronization requires an +> explicit `GUARDSCAN_TELEMETRY_URL`. ## Context GuardScan needed a backend infrastructure for optional telemetry and monitoring. The backend requirements were: @@ -150,8 +153,8 @@ We chose **Cloudflare Workers** as the backend infrastructure for GuardScan's op - **Memory**: ~10-20MB per request ## Related Decisions -- [ADR 002: Supabase PostgreSQL](./002-supabase-postgresql.md) - Why we chose Supabase for database - [ADR 003: Privacy-First Architecture](./003-privacy-first-architecture.md) - Privacy guarantees +- [ADR 007: Retire GuardScan-Hosted Cloudflare Telemetry](./007-retire-hosted-cloudflare-telemetry.md) - Current decision ## References - [Cloudflare Workers Documentation](https://developers.cloudflare.com/workers/) @@ -160,10 +163,6 @@ We chose **Cloudflare Workers** as the backend infrastructure for GuardScan's op - [Workers KV](https://developers.cloudflare.com/kv/) ## Review -This decision should be reviewed if: -- Cloudflare significantly changes pricing or features -- We need features not available on Workers -- We experience significant operational issues -- A clearly superior alternative emerges -**Next review date**: 2025-05-19 (6 months) +This historical decision is closed. Any future GuardScan-hosted telemetry +service requires a new ADR and cannot reactivate this one implicitly. diff --git a/docs/adrs/003-privacy-first-architecture.md b/docs/adrs/003-privacy-first-architecture.md index 0dc264e..d44e7cf 100644 --- a/docs/adrs/003-privacy-first-architecture.md +++ b/docs/adrs/003-privacy-first-architecture.md @@ -2,7 +2,7 @@ ## Status -Accepted; amended July 20, 2026. +Accepted; amended August 2, 2026. ## Context @@ -13,7 +13,12 @@ GuardScan analyzes sensitive repositories. Its product contract must distinguish - Built-in static analysis runs locally and does not upload source to GuardScan. - Cloud AI is BYOK and sends selected context directly to the configured provider; loopback Ollama and LM Studio remain local options. - Offline mode blocks GuardScan cloud AI, cloud embeddings, advisory lookups, update checks, and telemetry recording and delivery. -- Telemetry is disabled by default, requires explicit consent, queues locally, and is delivered only by `guardscan telemetry sync` to a user-configured HTTPS collector. +- Telemetry is disabled by default, requires explicit consent, queues locally, + and is delivered only by `guardscan telemetry sync` to a user-operated HTTPS + collector selected with `GUARDSCAN_TELEMETRY_URL`. +- GuardScan does not operate a hosted telemetry collector or provide a default + endpoint. The former Cloudflare service at `api.guardscancli.com` is retired + under [ADR 007](./007-retire-hosted-cloudflare-telemetry.md). - Telemetry contains only event ID, action category, aggregate LOC, duration, coarse execution mode, and timestamp. - Telemetry excludes installation and repository identifiers, source, paths, prompts, responses, findings, model names, languages, errors, dependency data, and arbitrary metadata. - Disabling telemetry deletes queued events. Local telemetry is retained for at most 30 days and 1,000 events while consent remains enabled. @@ -22,11 +27,15 @@ GuardScan analyzes sensitive repositories. Its product contract must distinguish ## Consequences - GuardScan cannot correlate anonymous telemetry across installations or repositories. -- Product analytics are intentionally limited to explicitly synchronized aggregate events. +- GuardScan receives no first-party product telemetry. A user-operated + collector may receive only explicitly synchronized aggregate events. - Cloud-provider and advisory-service privacy terms remain separate from GuardScan telemetry. - The CLI remains useful without a GuardScan backend, account, or network connection. - Privacy regressions require release-blocking tests for persistent and command-level offline controls. ## Active contract -The wire schema is `guardscan.telemetry.v1`; see [API Documentation](../API.md). The earlier client-ID, repository-hash, free-form metadata, monitoring endpoint, and automatic batching designs are retired and are not compatibility contracts. +The self-hosted collector wire schema is `guardscan.telemetry.v1`; see +[API Documentation](../API.md). The earlier client-ID, repository-hash, +free-form metadata, hosted monitoring endpoint, automatic batching, and +`GUARDSCAN_API_URL` designs are retired and are not compatibility contracts. diff --git a/docs/adrs/005-byok-ai-model.md b/docs/adrs/005-byok-ai-model.md index ee4806f..6a44f97 100644 --- a/docs/adrs/005-byok-ai-model.md +++ b/docs/adrs/005-byok-ai-model.md @@ -338,7 +338,7 @@ Or run: edit ~/.guardscan/config.yml: providers.openai.apiKey YOUR_KEY ## Related Decisions - [ADR 003: Privacy-First Architecture](./003-privacy-first-architecture.md) - Why direct requests -- [ADR 001: Cloudflare Workers Backend](./001-cloudflare-workers-backend.md) - Why no AI proxy +- [ADR 007: Retire GuardScan-Hosted Cloudflare Telemetry](./007-retire-hosted-cloudflare-telemetry.md) - Why GuardScan operates no hosted telemetry or AI proxy ## References - [OpenAI API Pricing](https://openai.com/pricing) diff --git a/docs/adrs/007-retire-hosted-cloudflare-telemetry.md b/docs/adrs/007-retire-hosted-cloudflare-telemetry.md new file mode 100644 index 0000000..d1eeafc --- /dev/null +++ b/docs/adrs/007-retire-hosted-cloudflare-telemetry.md @@ -0,0 +1,95 @@ +# ADR 007: Retire GuardScan-Hosted Cloudflare Telemetry + +## Status + +Accepted. + +Supersedes [ADR 001](./001-cloudflare-workers-backend.md). + +## Date + +2026-08-02 + +## Context + +GuardScan originally operated an optional telemetry backend on Cloudflare +Workers at `api.guardscancli.com`. The current CLI is local-first, has no +account requirement, and already supports a stricter model: telemetry is +disabled by default, queued locally after explicit consent, and synchronized +only when the user names a collector and runs `guardscan telemetry sync`. + +Continuing to operate a first-party ingestion service adds infrastructure, +privacy, retention, incident-response, and legacy-client obligations without +being necessary for the CLI product. The private `GuardScan-Monitoring` +repository may still support future Prometheus/Grafana work, but it is not a +GuardScan telemetry service or a compatibility promise to CLI users. + +## Decision + +- Permanently retire the GuardScan-hosted Cloudflare Worker and + `api.guardscancli.com` telemetry service. +- GuardScan will not provide a default or implicit telemetry endpoint. +- Self-hosted telemetry remains available only through an explicit + `GUARDSCAN_TELEMETRY_URL` value and the `guardscan telemetry sync` command. +- The legacy `GUARDSCAN_API_URL` setting is not part of the new contract. +- Consent allows events to be queued locally; it does not authorize background + delivery. Queued events remain local until an explicit successful sync or an + explicit clear/disable/reset action. +- After `1.1.0` is published, the retired endpoint will return `410 Gone` for + seven days without accepting, storing, or redirecting telemetry. The hosted + route is then deleted. Ownership of the apex domain and GuardScan website is + retained. +- `GuardScan-Monitoring` remains private and unchanged for possible future + Grafana work. Reusing it for CLI ingestion would require a new ADR and a new + explicit user contract. + +## Migration + +- Users of `1.0.5` and earlier should upgrade to `1.1.0` or disable telemetry + before the hosted endpoint is retired: + + ```bash + guardscan config --telemetry=false + ``` + +- Operators of a compatible self-hosted collector must replace + `GUARDSCAN_API_URL` with `GUARDSCAN_TELEMETRY_URL`. +- No queued event is migrated to GuardScan or another provider. Existing local + queues remain on the user's machine and retain the documented inspection, + explicit-sync, and deletion controls. +- The retired endpoint must not redirect. Older clients followed redirects, + which could forward telemetry to a different origin without a new user + decision. + +## Consequences + +### Positive + +- GuardScan no longer operates a telemetry ingestion or storage service. +- The network contract is explicit, local-first, and controlled by the user or + self-hosted collector operator. +- Cloudflare deployment, Worker, KV, DNS-route, and telemetry-data obligations + can be retired independently of the CLI. + +### Negative + +- GuardScan receives no first-party product telemetry. +- Existing self-hosted operators must update the environment variable name. +- Older clients may attempt the retired endpoint until they are upgraded or + telemetry is disabled; the temporary `410 Gone` response makes that failure + explicit without forwarding or accepting data. + +## Unchanged contracts + +- `guardscancli.com`, the GuardScan website, schema identifiers, package + metadata, and SBOM namespaces remain active. +- BYOK AI requests continue to go directly to the provider selected by the + user. +- Local static analysis, advisory lookups, update checks, and the private + `GuardScan-Monitoring` roadmap are separate decisions. + +## Related decisions + +- [ADR 001: Cloudflare Workers for Backend Infrastructure](./001-cloudflare-workers-backend.md) +- [ADR 003: Privacy-First Architecture](./003-privacy-first-architecture.md) +- [ADR 005: BYOK AI Model](./005-byok-ai-model.md) diff --git a/docs/adrs/README.md b/docs/adrs/README.md index e545529..5750223 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -16,18 +16,18 @@ Architecture Decision Records (ADRs) are a lightweight way to document important ### Backend & Infrastructure - [ADR 001: Cloudflare Workers for Backend](./001-cloudflare-workers-backend.md) - - **Status**: Accepted - - **Summary**: Use Cloudflare Workers for optional telemetry backend due to global performance, zero ops, and cost-effectiveness + - **Status**: Superseded by [ADR 007](./007-retire-hosted-cloudflare-telemetry.md) + - **Summary**: Historical platform decision for the retired hosted telemetry backend -- [ADR 002: Supabase PostgreSQL for Database](./002-supabase-postgresql.md) - - **Status**: Accepted - - **Summary**: Use Supabase PostgreSQL for telemetry storage due to REST API compatibility with Workers and excellent developer experience +ADR number 002 is intentionally unused. An earlier index entry referenced a +file that was never present in this repository, so it is not an active or +recoverable architecture decision. ### Architecture & Design - [ADR 003: Privacy-First Architecture](./003-privacy-first-architecture.md) - **Status**: Accepted - - **Summary**: All code analysis happens locally, source code never sent to servers, optional anonymized telemetry only + - **Summary**: Local analysis, direct BYOK providers, and optional explicit synchronization to user-operated telemetry collectors - [ADR 005: BYOK (Bring Your Own Key) AI Model](./005-byok-ai-model.md) - **Status**: Accepted @@ -37,6 +37,10 @@ Architecture Decision Records (ADRs) are a lightweight way to document important - **Status**: Proposed - **Summary**: Bundle GuardScan into host-native Node.js single executables for native package managers and optional pipx wheels +- [ADR 007: Retire GuardScan-Hosted Cloudflare Telemetry](./007-retire-hosted-cloudflare-telemetry.md) + - **Status**: Accepted + - **Summary**: Retire the hosted endpoint while preserving explicit user-operated telemetry collectors + ### Development & Tooling - [ADR 004: TypeScript Strict Mode](./004-typescript-strict-mode.md) From daceb4f0d1d6bdbca04a85fe8a6e7a299eef60f4 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 2 Aug 2026 21:05:41 -0400 Subject: [PATCH 20/23] wip: checkpoint GuardScan 1.1.0 release train --- .github/workflows/release-build.yml | 40 +- .github/workflows/release-canary.yml | 140 +++-- .../workflows/release-credential-health.yml | 583 ++++++++++++++++++ .github/workflows/release-publish.yml | 344 ++++++++++- .github/workflows/release-train.yml | 130 +++- QUICKSTART.md | 3 +- README.md | 21 +- cli/CHANGELOG.md | 48 +- cli/README.md | 21 +- .../documentation-command-contracts.test.ts | 116 ++++ .../contracts/release-contracts.test.ts | 32 + cli/__tests__/integration/rag-e2e.test.ts | 567 ++++++----------- .../release-credential-monitor.test.ts | 126 ++++ cli/__tests__/scripts/release-tool.test.ts | 37 ++ cli/__tests__/scripts/release-train.test.ts | 157 ++++- .../scripts/release-workflows.test.ts | 150 ++++- .../scripts/standalone-artifact.test.ts | 125 ++++ .../scripts/standalone-builder.test.ts | 52 ++ .../utils/runtime-capabilities.test.ts | 77 +++ .../guardscan.release-event.v1.schema.json | 1 + .../guardscan.release-manifest.v1.schema.json | 45 ++ .../guardscan.release-state.v2.schema.json | 16 + cli/scripts/release/events.js | 27 + cli/scripts/release/index.js | 53 +- cli/scripts/release/lib.js | 39 +- cli/scripts/release/reconcile.js | 47 +- cli/scripts/release/recovery-source.js | 109 ++++ cli/scripts/release/standalone-artifact.js | 7 +- cli/scripts/release/standalone.js | 48 +- cli/src/commands/capabilities.ts | 12 + cli/src/index.ts | 3 + cli/src/utils/runtime-capabilities.ts | 95 +++ docs/FUNCTIONAL_ACCEPTANCE.md | 119 ++++ docs/RELEASE_AUTOMATION.md | 49 +- docs/RELEASE_ONBOARDING.md | 168 ++++- .../006-node-sea-standalone-distribution.md | 39 +- tasks/plan.md | 449 ++++++++++++++ tasks/session-handoff-2026-08-02.md | 125 ++++ tasks/todo.md | 87 +++ 39 files changed, 3731 insertions(+), 576 deletions(-) create mode 100644 .github/workflows/release-credential-health.yml create mode 100644 cli/__tests__/contracts/documentation-command-contracts.test.ts create mode 100644 cli/__tests__/scripts/release-credential-monitor.test.ts create mode 100644 cli/__tests__/scripts/standalone-artifact.test.ts create mode 100644 cli/__tests__/utils/runtime-capabilities.test.ts create mode 100644 cli/scripts/release/recovery-source.js create mode 100644 cli/src/commands/capabilities.ts create mode 100644 cli/src/utils/runtime-capabilities.ts create mode 100644 docs/FUNCTIONAL_ACCEPTANCE.md create mode 100644 tasks/session-handoff-2026-08-02.md diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 707414f..144368e 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -43,6 +43,8 @@ jobs: npm run lint:ratchet npm audit --omit=dev --audit-level=high npm run test:release + npm run test:package + npm run test:package-manager - run: git diff --check npm: @@ -220,6 +222,8 @@ jobs: APPLE_NOTARY_PRIVATE_KEY: ${{ secrets.APPLE_NOTARY_PRIVATE_KEY }} run: | IDENTITY="Developer ID Application: Nauman Tanwir ($APPLE_TEAM_ID)" + KEYCHAIN="$RUNNER_TEMP/guardscan-signing.keychain-db" + security find-identity -v -p codesigning "$KEYCHAIN" | grep -F "$IDENTITY" codesign --force --options runtime --timestamp --sign "$IDENTITY" standalone/guardscan codesign --verify --deep --strict --verbose=2 standalone/guardscan printf '%s' "$APPLE_NOTARY_PRIVATE_KEY" > "$RUNNER_TEMP/AuthKey.p8" @@ -256,6 +260,13 @@ jobs: cp "$RUNNER_TEMP/guardscan-notarization.dmg" standalone/stapled-notarization.dmg codesign -dvvv standalone/guardscan 2> standalone/apple-code-signing.txt + - name: Remove ephemeral Apple signing material + if: always() && matrix.os == 'darwin' + shell: bash + run: | + security delete-keychain "$RUNNER_TEMP/guardscan-signing.keychain-db" || true + rm -f "$RUNNER_TEMP/certificate.p12" "$RUNNER_TEMP/AuthKey.p8" + - name: Azure OIDC login if: matrix.os == 'windows' uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3 @@ -319,6 +330,32 @@ jobs: const tag = process.env.ARTIFACT_TAG; const base = `https://github.com/${process.env.GITHUB_REPOSITORY}/releases/download/${tag}`; const sbom = JSON.parse(fs.readFileSync('standalone-sbom/artifact-sbom.json')); + const prototype = JSON.parse(fs.readFileSync('standalone/standalone-prototype.json')); + const commit = cp.execFileSync('git', ['rev-parse', 'HEAD'], {encoding: 'utf8'}).trim(); + for (const [field, expected] of Object.entries({version, tag, commit})) { + if (prototype[field] !== expected) { + throw new Error(`standalone prototype ${field} does not match release source`); + } + } + if (prototype.platform?.os !== process.env.ARTIFACT_OS + || prototype.platform?.arch !== process.env.ARTIFACT_ARCH) { + throw new Error('standalone prototype platform does not match native target'); + } + const smoke = prototype.smoke; + const optionalCapabilities = smoke?.optionalCapabilities; + if (smoke?.valid !== true + || smoke.nodeAbsentFromPath !== true + || smoke.packageManagersAbsentFromPath !== true + || smoke.optionalCapabilitiesUnavailableSafely !== true + || !optionalCapabilities) { + throw new Error('standalone prototype lacks complete isolated smoke evidence'); + } + if (prototype.capabilities?.chartRendering + !== optionalCapabilities.chartRendering?.dependencyAvailable + || prototype.capabilities?.accurateTokenCounting + !== (optionalCapabilities.tokenCounting?.mode === 'accurate')) { + throw new Error('standalone prototype capability metadata conflicts with smoke evidence'); + } const signatureTypes = process.env.ARTIFACT_OS === 'darwin' ? ['apple-code-signing', 'apple-notarization'] : [process.env.ARTIFACT_OS === 'windows' ? 'authenticode' : 'sigstore']; @@ -332,12 +369,13 @@ jobs: schemaVersion: 'guardscan.standalone-evidence.v1', version, tag, - commit: cp.execFileSync('git', ['rev-parse', 'HEAD'], {encoding: 'utf8'}).trim(), + commit, platform: { os: process.env.ARTIFACT_OS, arch: process.env.ARTIFACT_ARCH, ...(process.env.ARTIFACT_LIBC ? {libc: process.env.ARTIFACT_LIBC} : {}), }, + optionalCapabilities, signatures: signatureTypes.map(type => ({ type, url: `${base}/${process.env.ARTIFACT_ID}.${signatureFile(type)}`, diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 8bf9468..6556120 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -25,10 +25,10 @@ jobs: runs-on: ubuntu-24.04 outputs: trains: ${{ steps.matrix.outputs.trains }} - implementation_ref: ${{ steps.matrix.outputs.implementation_ref }} steps: - name: Create short-lived release app token id: app + if: inputs.version == '' || vars.RELEASE_AUTOMATION_ENABLED == 'true' uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ vars.RELEASE_APP_ID }} @@ -50,7 +50,6 @@ jobs: > active.json fi echo "trains=$(node -p 'JSON.stringify(require("./active.json").trains || [])')" >> "$GITHUB_OUTPUT" - echo "implementation_ref=$(node -p 'require("./active.json").trains[0] ? `v${require("./active.json").trains[0].version}` : \"\"')" >> "$GITHUB_OUTPUT" package-managers: name: ${{ matrix.manager }} canary for ${{ matrix.train.version }} @@ -101,37 +100,41 @@ jobs: env: VERSION: ${{ matrix.train.version }} run: | + assert_version() { + ACTUAL_VERSION="$("$@" | tr -d '\r')" + test "$ACTUAL_VERSION" = "$VERSION" + } case "${{ matrix.manager }}" in npm) npm install --global "guardscan@$VERSION" - guardscan --version - npx --yes "guardscan@$VERSION" --version + assert_version guardscan --version + assert_version npx --yes "guardscan@$VERSION" --version npm uninstall --global guardscan ;; pnpm) pnpm config set global-bin-dir "$RUNNER_TEMP/pnpm-bin" export PATH="$RUNNER_TEMP/pnpm-bin:$PATH" pnpm add --global "guardscan@$VERSION" - guardscan --version - pnpm dlx "guardscan@$VERSION" --version + assert_version guardscan --version + assert_version pnpm dlx "guardscan@$VERSION" --version pnpm remove --global guardscan ;; yarn-classic) corepack enable corepack prepare yarn@1.22.22 --activate yarn global add "guardscan@$VERSION" - "$(yarn global bin)/guardscan" --version + assert_version "$(yarn global bin)/guardscan" --version yarn global remove guardscan ;; yarn-modern) corepack enable corepack prepare yarn@4.9.2 --activate - yarn dlx "guardscan@$VERSION" --version + assert_version yarn dlx "guardscan@$VERSION" --version ;; bun) bun add --global "guardscan@$VERSION" - guardscan --version - bunx "guardscan@$VERSION" --version + assert_version guardscan --version + assert_version bunx "guardscan@$VERSION" --version bun remove --global guardscan ;; esac @@ -266,14 +269,15 @@ jobs: EXE="$PWD/executable/$EXECUTABLE" ( cd project - env -i \ + ACTUAL_VERSION="$(env -i \ HOME="$PWD/../isolated-home" \ USERPROFILE="$PWD/../isolated-home" \ GUARDSCAN_HOME="$PWD/../isolated-home/.guardscan" \ GUARDSCAN_NO_TELEMETRY=true \ GUARDSCAN_OFFLINE=true \ PATH="" \ - "$EXE" --version + "$EXE" --version | tr -d '\r')" + test "$ACTUAL_VERSION" = "$VERSION" env -i HOME="$PWD/../isolated-home" PATH="" "$EXE" --help env -i HOME="$PWD/../isolated-home" PATH="" "$EXE" \ --no-telemetry scan --offline --no-cve --skip-tests --skip-ai \ @@ -299,10 +303,16 @@ jobs: run: | PYPI_VERSION="${VERSION/-rc./rc}" python -m pip install --no-deps "guardscan-cli==$PYPI_VERSION" - guardscan --version + test "$(guardscan --version | tr -d '\r')" = "$VERSION" + guardscan --help | grep -q scan python -m pip uninstall -y guardscan-cli python -m pip install pipx python -m pipx install --pip-args=--no-deps "guardscan-cli==$PYPI_VERSION" + PIPX_BIN_DIR="$(python -m pipx environment --value PIPX_BIN_DIR)" + PIPX_GUARDSCAN="$PIPX_BIN_DIR/guardscan" + if [ -f "$PIPX_GUARDSCAN.exe" ]; then PIPX_GUARDSCAN="$PIPX_GUARDSCAN.exe"; fi + test "$("$PIPX_GUARDSCAN" --version | tr -d '\r')" = "$VERSION" + "$PIPX_GUARDSCAN" --help | grep -q scan python -m pipx runpip guardscan-cli show guardscan-cli python -m pipx uninstall guardscan-cli python - <<'PY' @@ -383,7 +393,7 @@ jobs: brew install ntanwir10/tap/guardscan brew test ntanwir10/tap/guardscan brew upgrade --dry-run ntanwir10/tap/guardscan - guardscan --version + test "$(guardscan --version | tr -d '\r')" = "$VERSION" brew uninstall guardscan else git clone --branch "channel-preview/v$VERSION" \ @@ -391,7 +401,7 @@ jobs: brew style preview-tap/Formula/guardscan.rb brew audit --strict preview-tap/Formula/guardscan.rb brew install --formula preview-tap/Formula/guardscan.rb - guardscan --version + test "$(guardscan --version | tr -d '\r')" = "$VERSION" brew uninstall guardscan fi node -e ' @@ -421,8 +431,10 @@ jobs: } scoop install $package if ($LASTEXITCODE -ne 0) { throw 'Scoop installation failed' } - guardscan --version - if ($LASTEXITCODE -ne 0) { throw 'Scoop invocation failed' } + $installedVersion = (& guardscan --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $installedVersion -ne $env:VERSION) { + throw 'Scoop invocation or version check failed' + } scoop update guardscan if ($LASTEXITCODE -ne 0) { throw 'Scoop update check failed' } scoop uninstall guardscan @@ -468,7 +480,6 @@ jobs: if: matrix.train.channel == 'stable' shell: pwsh run: | - $reports = @() foreach ($channel in @('winget', 'chocolatey')) { $report = @{ version = '${{ matrix.train.version }}' @@ -478,29 +489,34 @@ jobs: target = 'windows-x64' evidenceUrl = "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" } + $discovered = $false try { if ($channel -eq 'winget') { winget show --exact --id NaumanTanwir.GuardScan --version "${{ matrix.train.version }}" - if ($LASTEXITCODE -ne 0) { throw 'WinGet package is not public yet' } - winget install --exact --id NaumanTanwir.GuardScan --version "${{ matrix.train.version }}" --accept-package-agreements --accept-source-agreements - if ($LASTEXITCODE -ne 0) { throw 'WinGet installation failed' } - guardscan --version - if ($LASTEXITCODE -ne 0) { throw 'WinGet invocation failed' } - winget uninstall --exact --id NaumanTanwir.GuardScan - if ($LASTEXITCODE -ne 0) { throw 'WinGet uninstall failed' } + if ($LASTEXITCODE -eq 0) { + $discovered = $true + winget install --exact --id NaumanTanwir.GuardScan --version "${{ matrix.train.version }}" --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { throw 'WinGet installation failed' } + $installedVersion = (& guardscan --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $installedVersion -ne '${{ matrix.train.version }}') { throw 'WinGet invocation or version check failed' } + winget uninstall --exact --id NaumanTanwir.GuardScan + if ($LASTEXITCODE -ne 0) { throw 'WinGet uninstall failed' } + } } else { choco info guardscan --version "${{ matrix.train.version }}" --source https://community.chocolatey.org/api/v2/ - if ($LASTEXITCODE -ne 0) { throw 'Chocolatey package is not public yet' } - choco install guardscan --version "${{ matrix.train.version }}" --yes - if ($LASTEXITCODE -ne 0) { throw 'Chocolatey installation failed' } - guardscan --version - if ($LASTEXITCODE -ne 0) { throw 'Chocolatey invocation failed' } - choco uninstall guardscan --yes - if ($LASTEXITCODE -ne 0) { throw 'Chocolatey uninstall failed' } + if ($LASTEXITCODE -eq 0) { + $discovered = $true + choco install guardscan --version "${{ matrix.train.version }}" --yes + if ($LASTEXITCODE -ne 0) { throw 'Chocolatey installation failed' } + $installedVersion = (& guardscan --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $installedVersion -ne '${{ matrix.train.version }}') { throw 'Chocolatey invocation or version check failed' } + choco uninstall guardscan --yes + if ($LASTEXITCODE -ne 0) { throw 'Chocolatey uninstall failed' } + } } - $report.status = 'passed' + if ($discovered) { $report.status = 'passed' } } catch { - if ($_ -notmatch 'not found|No package found|Unable to find') { $report.status = 'failed' } + if ($discovered) { $report.status = 'failed' } } $report | ConvertTo-Json -Compress | Out-File "$channel.json" } @@ -516,7 +532,10 @@ jobs: record: name: Append canary evidence needs: [discover, package-managers, native-and-pypi, adapters, moderated] - if: always() && needs.discover.outputs.trains != '[]' + if: >- + always() + && vars.RELEASE_AUTOMATION_ENABLED == 'true' + && needs.discover.outputs.trains != '[]' runs-on: ubuntu-24.04 concurrency: group: release-ledger @@ -530,7 +549,7 @@ jobs: private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ needs.discover.outputs.implementation_ref }} + ref: ${{ github.event.repository.default_branch }} fetch-depth: 0 token: ${{ steps.app.outputs.token }} - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -561,6 +580,18 @@ jobs: || a.report.checkedAt.localeCompare(b.report.checkedAt) || a.file.localeCompare(b.file)); const versions = [...new Set(reports.map(({report}) => report.version))]; + const expectedTargetCounts = { + npm: 1, + pnpm: 1, + yarn: 2, + bun: 1, + github: 5, + pypi: 5, + homebrew: 2, + scoop: 1, + winget: 1, + chocolatey: 1, + }; fs.mkdirSync('ledgers', {recursive: true}); for (const version of versions) { const tag = `v${version}`; @@ -574,7 +605,8 @@ jobs: const existing = readEvents(ledger); if (existing.length === 0) throw new Error(`release ledger is empty for ${tag}`); const commit = existing[0].commit; - for (const {file, report} of reports.filter(item => item.report.version === version)) { + const versionReports = reports.filter(item => item.report.version === version); + for (const {file, report} of versionReports) { if (report.status === 'pending') continue; const suffix = `${process.env.GITHUB_RUN_ID}:${process.env.GITHUB_RUN_ATTEMPT}:${report.channel}:${report.target}:${path.basename(path.dirname(file))}`; appendEvent(ledger, { @@ -585,20 +617,40 @@ jobs: idempotencyKey: `canary:${suffix}`, payload: {status: report.status, evidenceUrl: report.evidenceUrl}, }); - if (report.status === 'passed') { - const types = ['winget', 'chocolatey'].includes(report.channel) + } + const versionAggregateTimestamp = versionReports + .filter(({report}) => report.status !== 'pending') + .map(({report}) => report.checkedAt) + .sort() + .at(-1); + for (const [channel, expectedCount] of Object.entries(expectedTargetCounts)) { + const channelReports = versionReports.filter(({report}) => report.channel === channel); + const resolved = channelReports.filter(({report}) => report.status !== 'pending'); + if (resolved.length === 0) continue; + const suffix = `${process.env.GITHUB_RUN_ID}:${process.env.GITHUB_RUN_ATTEMPT}:${channel}`; + if (resolved.some(({report}) => report.status !== 'passed')) { + appendEvent(ledger, { + version, tag, commit, + timestamp: versionAggregateTimestamp, + type: 'channel_failed', + channel, + idempotencyKey: `channel-failed:${suffix}`, + payload: {error: 'one or more current public canary targets failed'}, + }); + continue; + } + if (resolved.length === expectedCount) { + const types = ['winget', 'chocolatey'].includes(channel) ? ['channel_accepted', 'channel_verified'] : ['channel_verified']; for (const type of types) { appendEvent(ledger, { version, tag, commit, - timestamp: report.checkedAt, + timestamp: versionAggregateTimestamp, type, - channel: report.channel, + channel, idempotencyKey: `${type}:${suffix}`, - payload: ['homebrew', 'scoop'].includes(report.channel) - ? {} - : {remoteIdentity: `${report.channel}:${version}`}, + payload: {}, }); } } diff --git a/.github/workflows/release-credential-health.yml b/.github/workflows/release-credential-health.yml new file mode 100644 index 0000000..8d8422c --- /dev/null +++ b/.github/workflows/release-credential-health.yml @@ -0,0 +1,583 @@ +name: Release credential and provider health + +on: + workflow_dispatch: + schedule: + - cron: "17 9 * * *" + +concurrency: + group: release-credential-health + cancel-in-progress: false + +permissions: + contents: read + +jobs: + github-catalog: + name: GitHub App and shared catalog connectivity + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Mint least-privilege cross-repository installation token + id: app + continue-on-error: true + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap + permission-actions: write + permission-contents: write + permission-pull-requests: write + permission-issues: write + permission-workflows: write + permission-metadata: read + - name: Record GitHub App and catalog health + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + TOKEN_STEP_OUTCOME: ${{ steps.app.outcome }} + run: | + node - <<'NODE' + const cp = require('child_process'); + const fs = require('fs'); + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'github-app-catalog', + status: 'healthy', + checkedAt: new Date().toISOString(), + checks: {}, + expiry: {status: 'unknown', reason: 'GitHub does not expose private-key age or expiry to an installation token'}, + }; + let stage = 'token_mint'; + try { + if (process.env.TOKEN_STEP_OUTCOME !== 'success' || !process.env.GH_TOKEN) { + throw new Error('token_mint_failed'); + } + evidence.checks.tokenMint = {status: 'healthy'}; + const api = (endpoint, jq) => cp.execFileSync( + 'gh', + ['api', endpoint, ...(jq ? ['--jq', jq] : [])], + {encoding: 'utf8', env: process.env, stdio: ['ignore', 'pipe', 'pipe']} + ).trim(); + const apiRaw = endpoint => cp.execFileSync( + 'gh', + ['api', '-H', 'Accept: application/vnd.github.raw+json', endpoint], + {encoding: 'utf8', env: process.env, stdio: ['ignore', 'pipe', 'pipe']} + ); + stage = 'guardscan_connectivity'; + if (api('repos/ntanwir10/GuardScan', '.full_name') !== 'ntanwir10/GuardScan') { + throw new Error('guardscan_identity_mismatch'); + } + stage = 'catalog_connectivity'; + if (api('repos/ntanwir10/homebrew-tap', '.full_name') !== 'ntanwir10/homebrew-tap') { + throw new Error('catalog_identity_mismatch'); + } + stage = 'workflow_connectivity'; + const trainWorkflow = apiRaw( + 'repos/ntanwir10/GuardScan/contents/.github/workflows/release-train.yml' + ); + const catalogWorkflow = apiRaw( + 'repos/ntanwir10/homebrew-tap/contents/.github/workflows/verify.yml' + ); + if (!trainWorkflow.includes('*/30 * * * *') + || !trainWorkflow.includes('catalog_updated') + || !catalogWorkflow.includes('channel-lock.json')) { + throw new Error('workflow_contract_invalid'); + } + evidence.checks.repositories = { + status: 'healthy', + repositories: ['ntanwir10/GuardScan', 'ntanwir10/homebrew-tap'], + }; + evidence.checks.requestedPermissions = { + status: 'healthy', + note: 'token mint rejects permissions not granted to the installation', + }; + evidence.checks.catalogWorkflow = {status: 'healthy'}; + evidence.checks.scheduledReconciliation = {status: 'healthy'}; + evidence.checks.exactInstallationSet = { + status: 'unknown', + reason: 'least-privilege repository-scoped token cannot enumerate unrelated App installations', + }; + evidence.checks.catalogBranchProtection = { + status: 'unknown', + reason: 'required-check metadata needs administration read permission not granted to the release App', + }; + evidence.checks.dispatchPermission = { + status: 'unknown', + reason: 'non-publishing monitor does not emit a test dispatch', + }; + } catch { + evidence.status = 'unhealthy'; + evidence.checks.failure = {status: 'unhealthy', code: `${stage}_failed`}; + } + fs.writeFileSync('github-app-catalog-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + if (evidence.status === 'unhealthy') process.exitCode = 1; + NODE + - name: Upload sanitized GitHub health evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-github-app + path: github-app-catalog-health.json + if-no-files-found: error + retention-days: 30 + + trusted-publishers: + name: npm and Python trusted-publisher observability + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Record provider visibility boundaries + run: | + node - <<'NODE' + const fs = require('fs'); + const reason = 'trusted-publisher bindings are not exposed to this non-publishing workflow identity'; + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'trusted-publishers', + status: 'unknown', + checkedAt: new Date().toISOString(), + checks: { + npm: {status: 'unknown', reason, requiredRehearsal: 'candidate preflight'}, + testpypi: {status: 'unknown', reason, requiredRehearsal: 'TestPyPI publish and lifecycle canary'}, + pypi: {status: 'unknown', reason, requiredRehearsal: 'exact release-train OIDC subject'}, + }, + expiry: {status: 'unknown', reason: 'OIDC trusted publishers have no stored release token expiry'}, + }; + fs.writeFileSync('trusted-publishers-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + NODE + - name: Upload sanitized trusted-publisher evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-trusted-publishers + path: trusted-publishers-health.json + if-no-files-found: error + retention-days: 30 + + apple: + name: Apple certificate expiry and notary authentication + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: macos-15 + timeout-minutes: 15 + environment: apple-notarization + steps: + - name: Check certificate and perform read-only notary authentication + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + APPLE_NOTARY_PRIVATE_KEY: ${{ secrets.APPLE_NOTARY_PRIVATE_KEY }} + run: | + node - <<'NODE' + const cp = require('child_process'); + const fs = require('fs'); + const path = require('path'); + const temp = path.join(process.env.RUNNER_TEMP, 'guardscan-credential-monitor'); + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'apple-signing-notary', + status: 'unhealthy', + checkedAt: new Date().toISOString(), + checks: {}, + renewal: {status: 'external-authority-required'}, + }; + let stage = 'configuration'; + const run = (command, args) => { + const result = cp.spawnSync(command, args, { + encoding: 'utf8', env: process.env, stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0 || result.error) throw new Error(`${stage}_failed`); + return result.stdout.trim(); + }; + try { + const required = [ + 'APPLE_CERTIFICATE_P12', 'APPLE_CERTIFICATE_PASSWORD', 'APPLE_TEAM_ID', + 'APPLE_NOTARY_KEY_ID', 'APPLE_NOTARY_ISSUER_ID', 'APPLE_NOTARY_PRIVATE_KEY', + ]; + if (required.some(name => !process.env[name])) throw new Error('configuration_missing'); + fs.mkdirSync(temp, {recursive: true, mode: 0o700}); + const p12 = path.join(temp, 'certificate.p12'); + const pem = path.join(temp, 'certificate.pem'); + const notaryKey = path.join(temp, 'AuthKey.p8'); + fs.writeFileSync(p12, Buffer.from(process.env.APPLE_CERTIFICATE_P12, 'base64'), {mode: 0o600}); + fs.writeFileSync(notaryKey, process.env.APPLE_NOTARY_PRIVATE_KEY, {mode: 0o600}); + stage = 'certificate_decode'; + run('openssl', [ + 'pkcs12', '-in', p12, '-clcerts', '-nokeys', + '-passin', 'env:APPLE_CERTIFICATE_PASSWORD', '-out', pem, + ]); + stage = 'certificate_identity'; + const subject = run('openssl', ['x509', '-in', pem, '-noout', '-subject', '-nameopt', 'RFC2253']); + const teamMatched = subject.includes(process.env.APPLE_TEAM_ID); + if (!teamMatched) throw new Error('certificate_team_mismatch'); + const notAfter = run('openssl', ['x509', '-in', pem, '-noout', '-enddate']).replace(/^notAfter=/, ''); + const expiryMs = Date.parse(notAfter); + if (!Number.isFinite(expiryMs)) throw new Error('certificate_expiry_invalid'); + const daysRemaining = Math.floor((expiryMs - Date.now()) / 86400000); + const alertThresholds = [60, 30, 14, 7]; + evidence.checks.certificate = { + status: daysRemaining <= 0 ? 'unhealthy' : daysRemaining <= 60 ? 'warning' : 'healthy', + teamMatched, + expiresAt: new Date(expiryMs).toISOString(), + daysRemaining, + alertThresholdDays: alertThresholds, + crossedAlertThresholdDays: alertThresholds.filter(days => daysRemaining <= days), + }; + stage = 'notary_authentication'; + const history = run('xcrun', [ + 'notarytool', 'history', '--key', notaryKey, + '--key-id', process.env.APPLE_NOTARY_KEY_ID, + '--issuer', process.env.APPLE_NOTARY_ISSUER_ID, + '--output-format', 'json', + ]); + JSON.parse(history); + evidence.checks.notaryAuthentication = {status: 'healthy', operation: 'history-read'}; + evidence.status = daysRemaining <= 7 ? 'unhealthy' : daysRemaining <= 60 ? 'warning' : 'healthy'; + if (evidence.status === 'warning') { + console.error(`::warning::Apple signing certificate expires in ${daysRemaining} days`); + } + } catch (error) { + evidence.status = 'unhealthy'; + evidence.checks.failure = { + status: 'unhealthy', + code: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) + ? error.message : `${stage}_failed`, + }; + } finally { + fs.rmSync(temp, {recursive: true, force: true}); + } + fs.writeFileSync('apple-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + if (evidence.status === 'unhealthy') process.exitCode = 1; + NODE + - name: Upload sanitized Apple health evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-apple + path: apple-health.json + if-no-files-found: error + retention-days: 30 + + azure: + name: Azure OIDC and signing endpoint health + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: windows-signing + permissions: + contents: read + id-token: write + steps: + - name: Exchange GitHub OIDC identity with Azure + id: azure-login + continue-on-error: true + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Record Azure identity and non-signing preflight + env: + AZURE_LOGIN_OUTCOME: ${{ steps.azure-login.outcome }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_SIGNING_ACCOUNT: ${{ vars.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ vars.AZURE_SIGNING_PROFILE }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + run: | + node - <<'NODE' + const cp = require('child_process'); + const fs = require('fs'); + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'azure-artifact-signing', + status: 'unknown', + checkedAt: new Date().toISOString(), + checks: {}, + expiry: {status: 'unknown', reason: 'Azure OIDC federation has no stored client-secret expiry'}, + }; + let stage = 'oidc_login'; + try { + const required = [ + 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID', 'AZURE_CLIENT_ID', + 'AZURE_SIGNING_ACCOUNT', 'AZURE_SIGNING_PROFILE', 'AZURE_SIGNING_ENDPOINT', + ]; + if (required.some(name => !process.env[name])) throw new Error('configuration_missing'); + if (process.env.AZURE_LOGIN_OUTCOME !== 'success') throw new Error('oidc_login_failed'); + const accountResult = cp.spawnSync('az', ['account', 'show', '--output', 'json'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }); + if (accountResult.status !== 0 || accountResult.error) throw new Error('account_identity_failed'); + const account = JSON.parse(accountResult.stdout); + if (String(account.tenantId).toLowerCase() !== process.env.AZURE_TENANT_ID.toLowerCase() + || String(account.id).toLowerCase() !== process.env.AZURE_SUBSCRIPTION_ID.toLowerCase()) { + throw new Error('account_identity_mismatch'); + } + evidence.checks.oidc = {status: 'healthy'}; + evidence.checks.accountIdentity = {status: 'healthy', tenantMatched: true, subscriptionMatched: true}; + stage = 'signing_endpoint'; + const endpoint = new URL(process.env.AZURE_SIGNING_ENDPOINT); + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password) { + throw new Error('signing_endpoint_invalid'); + } + const endpointResult = cp.spawnSync('curl', [ + '--silent', '--show-error', '--output', '/dev/null', '--head', + '--max-time', '15', endpoint.toString(), + ], {stdio: ['ignore', 'ignore', 'pipe']}); + if (endpointResult.status !== 0 || endpointResult.error) throw new Error('signing_endpoint_unreachable'); + evidence.checks.endpointConnectivity = {status: 'healthy'}; + evidence.checks.signingProfile = {status: 'unknown', reason: 'configured profile cannot be queried without a management resource identifier and signer-role inspection'}; + evidence.checks.federatedSubject = {status: 'unknown', reason: 'Azure does not expose federated-subject metadata through the configured data-plane coordinates'}; + evidence.checks.signerRole = {status: 'unknown', reason: 'role assignment is not safely inferable from OIDC login alone'}; + } catch (error) { + evidence.status = 'unhealthy'; + evidence.checks.failure = { + status: 'unhealthy', + code: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) + ? error.message : `${stage}_failed`, + }; + } + fs.writeFileSync('azure-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + if (evidence.status === 'unhealthy') process.exitCode = 1; + NODE + - name: Upload sanitized Azure health evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-azure + path: azure-health.json + if-no-files-found: error + retention-days: 30 + + winget: + name: WinGet submitter token identity and expiry metadata + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: winget + steps: + - name: Run read-only WinGet submitter authentication preflight + env: + GH_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + node - <<'NODE' + const cp = require('child_process'); + const fs = require('fs'); + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'winget-submitter', + status: 'unknown', + checkedAt: new Date().toISOString(), + checks: { + claStatus: {status: 'unknown', reason: 'Microsoft CLA state is not exposed by a non-submitting GitHub API request'}, + submissionPermission: {status: 'unknown', reason: 'write capability is intentionally not exercised by this monitor'}, + }, + expiry: {status: 'unknown', reason: 'GitHub did not expose token-expiration metadata'}, + }; + let stage = 'configuration'; + const run = args => { + const result = cp.spawnSync('gh', args, { + encoding: 'utf8', env: process.env, stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0 || result.error) throw new Error(`${stage}_failed`); + return result.stdout; + }; + try { + if (!process.env.GH_TOKEN) throw new Error('configuration_missing'); + stage = 'token_identity'; + const response = run(['api', '--include', 'user']); + const bodyIndex = response.indexOf('{'); + if (bodyIndex < 0) throw new Error('token_identity_invalid'); + const headers = response.slice(0, bodyIndex); + const user = JSON.parse(response.slice(bodyIndex)); + if (user.login !== 'ntanwir10') throw new Error('token_identity_mismatch'); + evidence.checks.tokenIdentity = {status: 'healthy', login: user.login}; + stage = 'package_repository_connectivity'; + const repository = JSON.parse(run(['api', 'repos/microsoft/winget-pkgs'])); + if (repository.full_name !== 'microsoft/winget-pkgs') throw new Error('repository_identity_mismatch'); + evidence.checks.packageRepository = {status: 'healthy'}; + const expiryHeader = /^github-authentication-token-expiration:\s*(.+)$/im.exec(headers); + const scopesHeader = /^x-oauth-scopes:\s*(.*)$/im.exec(headers); + evidence.checks.scopeMetadata = scopesHeader && scopesHeader[1].trim() + ? {status: 'observed', scopes: scopesHeader[1].split(',').map(value => value.trim()).filter(Boolean)} + : {status: 'unknown', reason: 'GitHub did not expose token-scope metadata for this token type'}; + if (expiryHeader) { + const expiryMs = Date.parse(expiryHeader[1].trim()); + if (Number.isFinite(expiryMs)) { + const daysRemaining = Math.floor((expiryMs - Date.now()) / 86400000); + evidence.expiry = { + status: 'known', + expiresAt: new Date(expiryMs).toISOString(), + daysRemaining, + alertThresholdDays: [30, 14, 7], + }; + if (daysRemaining <= 0) evidence.status = 'unhealthy'; + else if (daysRemaining <= 30) evidence.status = 'warning'; + } + } + } catch (error) { + evidence.status = 'unhealthy'; + evidence.checks.failure = { + status: 'unhealthy', + code: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) + ? error.message : `${stage}_failed`, + }; + } + fs.writeFileSync('winget-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + if (evidence.status === 'warning') console.error('::warning::WinGet submitter token is within its expiry alert window'); + if (evidence.status === 'unhealthy') process.exitCode = 1; + NODE + - name: Upload sanitized WinGet health evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-winget + path: winget-health.json + if-no-files-found: error + retention-days: 30 + + chocolatey: + name: Chocolatey non-publishing provider visibility + if: >- + vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: chocolatey + steps: + - name: Check configuration and public repository connectivity + env: + CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} + run: | + node - <<'NODE' + const cp = require('child_process'); + const fs = require('fs'); + const evidence = { + schemaVersion: 'guardscan.credential-health.v1', + provider: 'chocolatey-publisher', + status: 'unknown', + checkedAt: new Date().toISOString(), + checks: { + apiKeyAuthentication: {status: 'unknown', reason: 'Chocolatey exposes no documented non-publishing API-key authentication check'}, + accountOwnership: {status: 'unknown', reason: 'publisher ownership is not exposed by the public package feed'}, + }, + expiry: {status: 'unknown', reason: 'Chocolatey does not expose API-key expiry metadata'}, + }; + try { + if (!process.env.CHOCO_API_KEY) throw new Error('configuration_missing'); + evidence.checks.apiKeyConfigured = {status: 'healthy'}; + const result = cp.spawnSync('curl', [ + '--fail', '--silent', '--show-error', '--output', '/dev/null', + '--max-time', '15', 'https://community.chocolatey.org/api/v2/', + ], {stdio: ['ignore', 'ignore', 'pipe']}); + if (result.status !== 0 || result.error) throw new Error('public_repository_unreachable'); + evidence.checks.publicRepository = {status: 'healthy'}; + } catch (error) { + evidence.status = 'unhealthy'; + evidence.checks.failure = { + status: 'unhealthy', + code: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) + ? error.message : 'provider_check_failed', + }; + } + fs.writeFileSync('chocolatey-health.json', `${JSON.stringify(evidence, null, 2)}\n`, {mode: 0o600}); + if (evidence.status === 'unhealthy') process.exitCode = 1; + NODE + - name: Upload sanitized Chocolatey health evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: credential-health-chocolatey + path: chocolatey-health.json + if-no-files-found: error + retention-days: 30 + + report: + name: Aggregate sanitized provider health + if: >- + always() + && (vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true' + || vars.RELEASE_AUTOMATION_ENABLED == 'true') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + needs: [github-catalog, trusted-publishers, apple, azure, winget, chocolatey] + steps: + - name: Download provider evidence + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: credential-health-* + path: credential-health-evidence + merge-multiple: true + - name: Build machine-readable health report + run: | + node - <<'NODE' + const fs = require('fs'); + const path = require('path'); + const root = 'credential-health-evidence'; + const providers = fs.readdirSync(root) + .filter(name => name.endsWith('.json')) + .map(name => JSON.parse(fs.readFileSync(path.join(root, name), 'utf8'))) + .sort((left, right) => left.provider.localeCompare(right.provider)); + const expected = [ + 'apple-signing-notary', 'azure-artifact-signing', 'chocolatey-publisher', + 'github-app-catalog', 'trusted-publishers', 'winget-submitter', + ]; + if (providers.length !== expected.length + || providers.some((item, index) => ( + item.schemaVersion !== 'guardscan.credential-health.v1' + || item.provider !== expected[index] + || !['healthy', 'warning', 'unknown', 'unhealthy'].includes(item.status) + ))) { + throw new Error('provider health evidence is missing, duplicated, or malformed'); + } + const statuses = new Set(providers.map(item => item.status)); + const status = statuses.has('unhealthy') ? 'unhealthy' + : statuses.has('warning') ? 'warning' + : statuses.has('unknown') ? 'unknown' : 'healthy'; + const report = { + schemaVersion: 'guardscan.credential-health-report.v1', + status, + checkedAt: new Date().toISOString(), + providers, + }; + fs.writeFileSync('credential-health-report.json', `${JSON.stringify(report, null, 2)}\n`, {mode: 0o600}); + const summary = [ + '## Release credential and provider health', + '', + `Overall: **${status}**`, + '', + '| Provider | Status |', + '| --- | --- |', + ...providers.map(item => `| ${item.provider} | ${item.status} |`), + '', + 'Unknown means the provider does not safely expose that state to this non-publishing workflow.', + ]; + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.join('\n')}\n`); + NODE + - name: Upload aggregate credential health report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-credential-health-report + path: credential-health-report.json + if-no-files-found: error + retention-days: 30 + - name: Fail closed on unhealthy provider checks + run: | + node -e "const report=require('./credential-health-report.json'); if(report.status==='unhealthy') process.exit(1)" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 016df6a..30cf390 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -19,6 +19,8 @@ permissions: env: RELEASE_NODE_VERSION: 22.23.1 RELEASE_NPM_VERSION: 11.5.2 + RELEASE_WINGETCREATE_VERSION: 1.12.13.0 + RELEASE_WINGETCREATE_SHA256: 24042bd37915805615e6cf969ac57c6439124c3fe85823327f5f3fb24bd9ffea jobs: github: @@ -139,7 +141,7 @@ jobs: run: npm run release:npm-preflight -- --artifact-dir ../npm-artifact pypi-test: - name: TestPyPI trusted publication and lifecycle + name: TestPyPI trusted publication if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 needs: [github] @@ -229,31 +231,86 @@ jobs: raise SystemExit("TestPyPI did not converge to the complete tested wheel set") time.sleep(10) PY - - name: Convert release tag to PEP 440 - id: version + + pypi-test-lifecycle: + name: TestPyPI lifecycle on ${{ matrix.target.id }} + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' + needs: [pypi-test] + runs-on: ${{ matrix.target.runner }} + strategy: + fail-fast: false + matrix: + target: + - id: linux-x64-glibc + runner: ubuntu-24.04 + - id: linux-arm64-glibc + runner: ubuntu-24.04-arm + - id: darwin-arm64 + runner: macos-15 + - id: darwin-x64 + runner: macos-15-intel + - id: windows-x64 + runner: windows-2025 + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - name: Test TestPyPI wheel through pip and pipx on ${{ matrix.target.id }} shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} run: | - VERSION="${{ inputs.tag }}" - VERSION="${VERSION#v}" - VERSION="${VERSION/-rc./rc}" - echo "value=$VERSION" >> "$GITHUB_OUTPUT" - - name: Test TestPyPI wheel through pip and pipx - run: | + VERSION="${RELEASE_TAG#v}" + PYPI_VERSION="${VERSION/-rc./rc}" + exercise_guardscan() { + executable="$1" + label="$2" + project="$PWD/testpypi-$label" + rm -rf "$project" + mkdir -p "$project" + printf '{"name":"guardscan-testpypi","version":"1.0.0","private":true}\n' \ + > "$project/package.json" + printf 'module.exports = 42;\n' > "$project/index.js" + ( + cd "$project" + test "$("$executable" --version | tr -d '\r')" = "$VERSION" + "$executable" --help > help.txt + grep -q scan help.txt + "$executable" --no-telemetry scan --offline --no-cve --skip-tests --skip-ai \ + --format json --output scan.json + python - <<'PY' + import json + scan = json.load(open('scan.json', encoding='utf-8')) + if ( + scan.get('schemaVersion') != 'guardscan.scan.v1' + or scan.get('run', {}).get('executionMode') != 'static-analysis' + or scan.get('run', {}).get('offline') is not True + ): + raise SystemExit('TestPyPI offline scan contract failed') + PY + ) + } + python -m pip install --index-url https://test.pypi.org/simple/ \ - --no-deps "guardscan-cli==${{ steps.version.outputs.value }}" - guardscan --version + --no-deps "guardscan-cli==$PYPI_VERSION" + exercise_guardscan guardscan pip python -m pip uninstall -y guardscan-cli + python -m pip install pipx python -m pipx install --index-url https://test.pypi.org/simple/ \ - --pip-args=--no-deps "guardscan-cli==${{ steps.version.outputs.value }}" - "$HOME/.local/bin/guardscan" --version + --pip-args=--no-deps "guardscan-cli==$PYPI_VERSION" + PIPX_BIN_DIR="$(python -m pipx environment --value PIPX_BIN_DIR)" + GUARDSCAN="$PIPX_BIN_DIR/guardscan" + if [ -f "$GUARDSCAN.exe" ]; then GUARDSCAN="$GUARDSCAN.exe"; fi + exercise_guardscan "$GUARDSCAN" pipx + python -m pipx runpip guardscan-cli show guardscan-cli python -m pipx uninstall guardscan-cli pypi: name: PyPI trusted publication if: vars.RELEASE_AUTOMATION_ENABLED == 'true' runs-on: ubuntu-24.04 - needs: [pypi-test] + needs: [pypi-test-lifecycle] environment: pypi permissions: contents: read @@ -549,6 +606,9 @@ jobs: - name: Validate and install WinGet local manifest shell: pwsh run: | + if ($null -eq (Get-Command winget -ErrorAction SilentlyContinue)) { + throw 'WinGet is unavailable on the selected Windows runner' + } $installer = Get-ChildItem adapters\winget -Filter '*.installer.yaml' -Recurse | Select-Object -First 1 if ($null -eq $installer) { throw 'Rendered WinGet installer manifest is missing' } $manifestDirectory = $installer.Directory.FullName @@ -556,18 +616,174 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'WinGet manifest validation failed' } winget install --manifest $manifestDirectory --accept-package-agreements --accept-source-agreements if ($LASTEXITCODE -ne 0) { throw 'WinGet local manifest installation failed' } - guardscan --version - if ($LASTEXITCODE -ne 0) { throw 'WinGet local manifest invocation failed' } + $installedVersion = (& guardscan --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $installedVersion -ne '${{ inputs.tag }}'.TrimStart('v')) { + throw 'WinGet local manifest invocation or version check failed' + } winget uninstall --exact --id NaumanTanwir.GuardScan if ($LASTEXITCODE -ne 0) { throw 'WinGet local manifest uninstall failed' } - - name: Submit WinGet manifests + - name: Preflight and submit WinGet manifests with exact evidence shell: pwsh env: + GH_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + RELEASE_TAG: ${{ inputs.tag }} WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} run: | - dotnet tool install --global wingetcreate - if ($LASTEXITCODE -ne 0) { throw 'wingetcreate installation failed' } - wingetcreate submit adapters\winget --token $env:WINGET_GITHUB_TOKEN + $ErrorActionPreference = 'Stop' + $version = $env:RELEASE_TAG.TrimStart('v') + $packageIdentity = "NaumanTanwir.GuardScan@$version" + $manifestPath = "manifests/n/NaumanTanwir/GuardScan/$version" + $installer = Get-ChildItem adapters\winget -Filter '*.installer.yaml' -Recurse | Select-Object -First 1 + if ($null -eq $installer) { throw 'Rendered WinGet installer manifest is missing' } + $manifestDirectory = $installer.Directory.FullName + $localFiles = [ordered]@{} + Get-ChildItem $manifestDirectory -File -Filter '*.yaml' | Sort-Object Name | ForEach-Object { + $localFiles[$_.Name] = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + if ($localFiles.Count -ne 3) { throw 'WinGet evidence requires exactly three rendered manifests' } + $digestInput = ($localFiles.GetEnumerator() | ForEach-Object { "$($_.Key)`0$($_.Value)`n" }) -join '' + [IO.File]::WriteAllText('winget-digest-input.txt', $digestInput, [Text.UTF8Encoding]::new($false)) + $manifestDigest = (Get-FileHash winget-digest-input.txt -Algorithm SHA256).Hash.ToLowerInvariant() + + function Write-WinGetEvidence($state, $remoteIdentity, $pullRequest, $commit, $publicBytesVerified) { + [ordered]@{ + schemaVersion = 'guardscan.moderated-submission.v1' + channel = 'winget' + version = $version + tag = $env:RELEASE_TAG + state = $state + packageIdentity = $packageIdentity + remoteIdentity = $remoteIdentity + remoteDigest = $manifestDigest + files = $localFiles + provider = [ordered]@{ + repository = 'microsoft/winget-pkgs' + path = $manifestPath + pullRequest = $pullRequest + commit = $commit + publicBytesVerified = $publicBytesVerified + pendingStateQuery = 'digest-bound open pull request, then protected release ledger' + } + checkedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + } | ConvertTo-Json -Depth 8 | Out-File winget-evidence.json -Encoding utf8 + } + + function Assert-WinGetRemoteFiles($remoteFiles) { + $expectedNames = @($localFiles.Keys | Sort-Object) + $actualNames = @($remoteFiles | ForEach-Object { Split-Path $_.filename -Leaf } | Sort-Object) + if (Compare-Object $expectedNames $actualNames) { + throw 'WinGet public manifest integrity conflict' + } + foreach ($remoteFile in $remoteFiles) { + $name = Split-Path $remoteFile.filename -Leaf + $destination = Join-Path $env:RUNNER_TEMP "winget-remote-$name" + Invoke-WebRequest $remoteFile.raw_url -OutFile $destination + $remoteDigest = (Get-FileHash $destination -Algorithm SHA256).Hash.ToLowerInvariant() + if ($remoteDigest -ne $localFiles[$name]) { + throw 'WinGet public manifest integrity conflict' + } + } + } + + function Find-WinGetPullRequest($submitter, $title) { + $query = "repo:microsoft/winget-pkgs is:pr is:open author:$submitter in:title `"$title`"" + $search = gh api --method GET search/issues -f q="$query" -f per_page=20 + if ($LASTEXITCODE -ne 0) { throw 'Unable to query pending WinGet pull requests reliably' } + $match = ($search | Out-String | ConvertFrom-Json).items | + Where-Object { $_.title -eq $title } | + Sort-Object updated_at -Descending | + Select-Object -First 1 + if ($null -eq $match) { return $null } + $pull = gh api "repos/microsoft/winget-pkgs/pulls/$($match.number)" + if ($LASTEXITCODE -ne 0) { throw 'Unable to read pending WinGet pull request' } + return ($pull | Out-String | ConvertFrom-Json) + } + + function Assert-WinGetPullRequest($pullRequest) { + $pullFiles = gh api "repos/microsoft/winget-pkgs/pulls/$($pullRequest.number)/files?per_page=100" + if ($LASTEXITCODE -ne 0) { throw 'Unable to read pending WinGet pull request files' } + $remoteFiles = @($pullFiles | Out-String | ConvertFrom-Json | + Where-Object { $_.filename -like "$manifestPath/*" }) + Assert-WinGetRemoteFiles $remoteFiles + } + + $publicListing = gh api "repos/microsoft/winget-pkgs/contents/${manifestPath}?ref=master" 2>&1 + $publicStatus = $LASTEXITCODE + if ($publicStatus -eq 0) { + $entries = @($publicListing | Out-String | ConvertFrom-Json) + $remoteFiles = @($entries | ForEach-Object { + [pscustomobject]@{ filename = "$manifestPath/$($_.name)"; raw_url = $_.download_url } + }) + Assert-WinGetRemoteFiles $remoteFiles + $catalogCommit = (gh api --method GET repos/microsoft/winget-pkgs/commits -f path="$manifestPath" -f per_page=1 --jq '.[0].sha').Trim() + if ($LASTEXITCODE -ne 0 -or $catalogCommit -notmatch '^[a-f0-9]{40}$') { + throw 'Unable to resolve exact public WinGet catalog commit' + } + Write-WinGetEvidence 'public-exact' "github:microsoft/winget-pkgs@$catalogCommit#$manifestPath" $null $catalogCommit $true + exit 0 + } + if (($publicListing | Out-String) -notmatch 'HTTP 404') { + throw 'Unable to query public WinGet catalog reliably' + } + + $submitter = (gh api user --jq .login).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($submitter)) { + throw 'Unable to resolve WinGet submitting identity' + } + $pullTitle = "GuardScan $version [$($manifestDigest.Substring(0, 12))]" + $pullRequest = Find-WinGetPullRequest $submitter $pullTitle + if ($null -ne $pullRequest) { + Assert-WinGetPullRequest $pullRequest + Write-WinGetEvidence 'pending' "github:microsoft/winget-pkgs/pull/$($pullRequest.number)@$($pullRequest.head.sha)#$manifestPath" $($pullRequest.number) $($pullRequest.head.sha) $false + exit 0 + } + + git fetch origin release-ledger + if ($LASTEXITCODE -ne 0) { throw 'Unable to fetch protected release ledger' } + $ledgerPath = "events/$($env:RELEASE_TAG).jsonl" + $ledgerEntry = git ls-tree --name-only origin/release-ledger -- $ledgerPath + if ($LASTEXITCODE -ne 0) { throw 'Unable to inspect protected release ledger' } + if (-not [string]::IsNullOrWhiteSpace(($ledgerEntry | Out-String))) { + $ledgerLines = git show "origin/release-ledger:$ledgerPath" + if ($LASTEXITCODE -ne 0) { throw 'Unable to read protected release ledger' } + $existing = $ledgerLines | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.type -eq 'channel_submitted' -and $_.channel -eq 'winget' } | + Select-Object -Last 1 + if ($null -ne $existing) { + if ($existing.payload.remoteDigest -ne $manifestDigest) { + throw 'WinGet protected-ledger digest conflicts with rendered manifests' + } + if ($null -eq $existing.payload.submission) { + throw 'WinGet protected-ledger evidence is incomplete' + } + $existing.payload.submission | ConvertTo-Json -Depth 8 | Out-File winget-evidence.json -Encoding utf8 + Write-Host 'WinGet pending provider state is not stronger than recorded evidence; the protected release ledger prevents a blind duplicate.' + exit 0 + } + } + + $wingetCreateUrl = "https://github.com/microsoft/winget-create/releases/download/v$env:RELEASE_WINGETCREATE_VERSION/wingetcreate.exe" + Invoke-WebRequest $wingetCreateUrl -OutFile wingetcreate.exe + $wingetCreateDigest = (Get-FileHash wingetcreate.exe -Algorithm SHA256).Hash.ToLowerInvariant() + if ($wingetCreateDigest -ne $env:RELEASE_WINGETCREATE_SHA256) { + throw 'Pinned wingetcreate executable failed SHA-256 verification' + } + & .\wingetcreate.exe submit --prtitle "$pullTitle" --no-open --token $env:WINGET_GITHUB_TOKEN $manifestDirectory + if ($LASTEXITCODE -ne 0) { throw 'WinGet submission failed' } + for ($attempt = 0; $attempt -lt 30; $attempt += 1) { + $pullRequest = Find-WinGetPullRequest $submitter $pullTitle + if ($null -ne $pullRequest) { break } + Start-Sleep -Seconds 10 + } + if ($null -eq $pullRequest) { throw 'Submitted WinGet pull request identity did not become queryable' } + Assert-WinGetPullRequest $pullRequest + Write-WinGetEvidence 'submitted' "github:microsoft/winget-pkgs/pull/$($pullRequest.number)@$($pullRequest.head.sha)#$manifestPath" $($pullRequest.number) $($pullRequest.head.sha) $false + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-winget-evidence-${{ inputs.tag }} + path: winget-evidence.json + if-no-files-found: error + retention-days: 30 chocolatey: name: Chocolatey submission @@ -593,6 +809,9 @@ jobs: - name: Pack and test Chocolatey from a local feed shell: pwsh run: | + if ($null -eq (Get-Command choco -ErrorAction SilentlyContinue)) { + throw 'Chocolatey is unavailable on the selected Windows runner' + } New-Item -ItemType Directory -Force local-feed | Out-Null Push-Location adapters\chocolatey choco pack guardscan.nuspec --output-directory ..\..\local-feed @@ -600,16 +819,95 @@ jobs: Pop-Location choco install guardscan --source "$(Resolve-Path local-feed)" --yes if ($LASTEXITCODE -ne 0) { throw 'Chocolatey local-feed installation failed' } - guardscan --version - if ($LASTEXITCODE -ne 0) { throw 'Chocolatey local-feed invocation failed' } + $installedVersion = (& guardscan --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $installedVersion -ne '${{ inputs.tag }}'.TrimStart('v')) { + throw 'Chocolatey local-feed invocation or version check failed' + } choco uninstall guardscan --yes if ($LASTEXITCODE -ne 0) { throw 'Chocolatey local-feed uninstall failed' } - - name: Submit Chocolatey package + - name: Preflight and submit Chocolatey package with exact evidence shell: pwsh env: CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} + RELEASE_TAG: ${{ inputs.tag }} run: | + $ErrorActionPreference = 'Stop' + $version = $env:RELEASE_TAG.TrimStart('v') $package = Get-ChildItem local-feed\*.nupkg | Select-Object -First 1 if ($null -eq $package) { throw 'Chocolatey package is missing' } + $packageDigest = (Get-FileHash $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $packageIdentity = "guardscan@$version" + $publicUrl = "https://community.chocolatey.org/api/v2/package/guardscan/$version" + + function Write-ChocolateyEvidence($state, $remoteIdentity, $publicBytesVerified) { + [ordered]@{ + schemaVersion = 'guardscan.moderated-submission.v1' + channel = 'chocolatey' + version = $version + tag = $env:RELEASE_TAG + state = $state + packageIdentity = $packageIdentity + packageFilename = $package.Name + remoteIdentity = $remoteIdentity + remoteDigest = $packageDigest + provider = [ordered]@{ + url = $publicUrl + publicBytesVerified = $publicBytesVerified + pendingStateQuery = 'public package bytes only; protected release ledger prevents a blind duplicate after recorded submission' + } + checkedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + } | ConvertTo-Json -Depth 8 | Out-File chocolatey-evidence.json -Encoding utf8 + } + + $public = $false + try { + Invoke-WebRequest $publicUrl -OutFile public-guardscan.nupkg + $public = $true + } catch { + $statusCode = if ($null -ne $_.Exception.Response) { + [int]$_.Exception.Response.StatusCode + } else { 0 } + if ($statusCode -ne 404) { throw 'Unable to query public Chocolatey package reliably' } + } + if ($public) { + $publicDigest = (Get-FileHash public-guardscan.nupkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($publicDigest -ne $packageDigest) { + throw 'Chocolatey public package integrity conflict' + } + Write-ChocolateyEvidence 'public-exact' $publicUrl $true + exit 0 + } + + git fetch origin release-ledger + if ($LASTEXITCODE -ne 0) { throw 'Unable to fetch protected release ledger' } + $ledgerPath = "events/$($env:RELEASE_TAG).jsonl" + $ledgerEntry = git ls-tree --name-only origin/release-ledger -- $ledgerPath + if ($LASTEXITCODE -ne 0) { throw 'Unable to inspect protected release ledger' } + if (-not [string]::IsNullOrWhiteSpace(($ledgerEntry | Out-String))) { + $ledgerLines = git show "origin/release-ledger:$ledgerPath" + if ($LASTEXITCODE -ne 0) { throw 'Unable to read protected release ledger' } + $existing = $ledgerLines | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.type -eq 'channel_submitted' -and $_.channel -eq 'chocolatey' } | + Select-Object -Last 1 + if ($null -ne $existing) { + if ($existing.payload.remoteDigest -ne $packageDigest) { + throw 'Chocolatey protected-ledger digest conflicts with rendered package' + } + if ($null -eq $existing.payload.submission) { + throw 'Chocolatey protected-ledger evidence is incomplete' + } + $existing.payload.submission | ConvertTo-Json -Depth 8 | Out-File chocolatey-evidence.json -Encoding utf8 + Write-Host 'Chocolatey pending moderation cannot be queried reliably; the protected release ledger prevents a blind duplicate.' + exit 0 + } + } + choco push $package.FullName --source https://push.chocolatey.org/ --api-key $env:CHOCO_API_KEY if ($LASTEXITCODE -ne 0) { throw 'Chocolatey submission failed' } + Write-ChocolateyEvidence 'submitted' "chocolatey:$packageIdentity" $false + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-chocolatey-evidence-${{ inputs.tag }} + path: chocolatey-evidence.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml index 13d59c3..990e229 100644 --- a/.github/workflows/release-train.yml +++ b/.github/workflows/release-train.yml @@ -183,12 +183,41 @@ jobs: ); } NODE - HEAD_SHA="$(gh pr view "${{ inputs.release_pr }}" \ - --repo "${GITHUB_REPOSITORY}" \ - --json headRefOid \ - --jq .headRefOid)" - test "${#HEAD_SHA}" = 40 + gh api "repos/${GITHUB_REPOSITORY}/pulls/${{ inputs.release_pr }}" > release-pr.json + HEAD_SHA="$(node - <<'NODE' + const pr = require('./release-pr.json'); + if (pr.state !== 'open') { + throw new Error('release PR must be OPEN'); + } + if (pr.base?.ref !== 'main') { + throw new Error('release PR must target main'); + } + if ( + pr.head?.repo?.full_name?.toLowerCase() + !== process.env.GITHUB_REPOSITORY.toLowerCase() + ) { + throw new Error('release PR must originate from this repository, not a fork'); + } + if (!/^[a-f0-9]{40}$/.test(pr.head?.sha || '')) { + throw new Error('release PR has no valid head commit'); + } + process.stdout.write(pr.head.sha); + NODE + )" echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "is_draft=$(node -p 'String(require("./release-pr.json").draft)')" >> "$GITHUB_OUTPUT" + - name: Mark validated release PR ready and recheck candidate source + if: inputs.action == 'candidate' + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + EXPECTED_HEAD: ${{ steps.source.outputs.head_sha }} + run: | + if [ "${{ steps.source.outputs.is_draft }}" = true ]; then + gh pr ready "${{ inputs.release_pr }}" --repo "${GITHUB_REPOSITORY}" + fi + CURRENT_HEAD="$(gh pr view "${{ inputs.release_pr }}" \ + --repo "${GITHUB_REPOSITORY}" --json headRefOid --jq .headRefOid)" + test "$CURRENT_HEAD" = "$EXPECTED_HEAD" - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ steps.source.outputs.head_sha }} @@ -401,6 +430,16 @@ jobs: with: name: release-catalog-${{ needs.prepare.outputs.tag }} path: catalog-evidence + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + if: needs.prepare.outputs.channel == 'stable' + with: + name: release-winget-evidence-${{ needs.prepare.outputs.tag }} + path: moderated-evidence/winget + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + if: needs.prepare.outputs.channel == 'stable' + with: + name: release-chocolatey-evidence-${{ needs.prepare.outputs.tag }} + path: moderated-evidence/chocolatey - name: Fetch ledger, append chained events, and push env: GH_TOKEN: ${{ steps.app.outputs.token }} @@ -417,6 +456,7 @@ jobs: const fs = require('fs'); const cp = require('child_process'); const {appendEvent, readEvents} = require('./cli/scripts/release/events'); + const {releaseTrainChannels} = require('./cli/scripts/release/lib'); const tag = process.env.RELEASE_TAG; const version = tag.slice(1); const commit = cp.execFileSync('git', ['rev-parse', 'HEAD'], {encoding: 'utf8'}).trim(); @@ -436,12 +476,7 @@ jobs: const base = {version, tag, commit}; if (readEvents(ledger).length === 0) { const idempotencyKey = `train:${tag}`; - const channels = [ - 'npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi', - ...(process.env.RELEASE_CHANNEL === 'stable' - ? ['homebrew-core', 'winget', 'chocolatey'] - : []), - ]; + const channels = releaseTrainChannels(process.env.RELEASE_CHANNEL); appendEvent(ledger, { ...base, timestamp: timestampFor(idempotencyKey), type: 'train_started', idempotencyKey, @@ -470,6 +505,67 @@ jobs: artifact.kind === 'standalone' && artifact.platform.os === 'windows' ).map(artifact => artifact.id), }; + const readModeratedEvidence = channel => { + const file = `moderated-evidence/${channel}/${channel}-evidence.json`; + const evidence = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); + if (evidence.schemaVersion !== 'guardscan.moderated-submission.v1' + || evidence.channel !== channel + || evidence.version !== version + || evidence.tag !== tag + || typeof evidence.packageIdentity !== 'string' + || evidence.packageIdentity.length === 0 + || typeof evidence.remoteIdentity !== 'string' + || evidence.remoteIdentity.length === 0 + || !/^[a-f0-9]{64}$/.test(evidence.remoteDigest || '') + || !['submitted', 'pending', 'pending-ledger', 'public-exact'].includes(evidence.state) + || typeof evidence.provider?.pendingStateQuery !== 'string') { + throw new Error(`${channel} submission evidence is invalid or belongs to another release`); + } + if (channel === 'winget') { + const expectedNames = [ + 'NaumanTanwir.GuardScan.installer.yaml', + 'NaumanTanwir.GuardScan.locale.en-US.yaml', + 'NaumanTanwir.GuardScan.yaml', + ]; + const names = Object.keys(evidence.files || {}).sort(); + if (JSON.stringify(names) !== JSON.stringify(expectedNames) + || names.some(name => !/^[a-f0-9]{64}$/.test(evidence.files[name]))) { + throw new Error('WinGet submission evidence has an invalid rendered manifest set'); + } + const digestInput = names.map(name => `${name}\0${evidence.files[name]}\n`).join(''); + const aggregate = crypto.createHash('sha256').update(digestInput).digest('hex'); + const expectedRemoteIdentity = evidence.state === 'public-exact' + ? `github:microsoft/winget-pkgs@${evidence.provider.commit}#${evidence.provider.path}` + : `github:microsoft/winget-pkgs/pull/${evidence.provider.pullRequest}@${evidence.provider.commit}#${evidence.provider.path}`; + if (aggregate !== evidence.remoteDigest + || evidence.packageIdentity !== `NaumanTanwir.GuardScan@${version}` + || evidence.provider.repository !== 'microsoft/winget-pkgs' + || evidence.provider.path !== `manifests/n/NaumanTanwir/GuardScan/${version}` + || !/^[a-f0-9]{40}$/.test(evidence.provider.commit || '') + || evidence.remoteIdentity !== expectedRemoteIdentity + || evidence.provider.publicBytesVerified !== (evidence.state === 'public-exact') + || (evidence.state !== 'public-exact' + && (!Number.isInteger(evidence.provider.pullRequest) + || evidence.provider.pullRequest <= 0))) { + throw new Error('WinGet submission evidence does not match the rendered release identity'); + } + } else { + const expectedRemoteIdentity = evidence.state === 'public-exact' + ? evidence.provider.url + : `chocolatey:${evidence.packageIdentity}`; + if (evidence.packageIdentity !== `guardscan@${version}` + || evidence.packageFilename !== `guardscan.${version}.nupkg` + || evidence.provider.url !== `https://community.chocolatey.org/api/v2/package/guardscan/${version}` + || evidence.remoteIdentity !== expectedRemoteIdentity + || evidence.provider.publicBytesVerified !== (evidence.state === 'public-exact')) { + throw new Error('Chocolatey submission evidence does not match the rendered package identity'); + } + } + const normalized = {...evidence}; + delete normalized.checkedAt; + normalized.state = evidence.state === 'public-exact' ? 'public-exact' : 'pending'; + return normalized; + }; for (const channel of ['github', 'npm', 'pypi']) { const idempotencyKey = `published:${channel}:${tag}`; appendEvent(ledger, { @@ -514,14 +610,20 @@ jobs: } if (process.env.RELEASE_CHANNEL === 'stable') { for (const channel of ['winget', 'chocolatey']) { - const idempotencyKey = `submitted:${channel}:${tag}`; + const evidence = readModeratedEvidence(channel); + const identityKey = crypto.createHash('sha256') + .update(evidence.remoteIdentity) + .digest('hex') + .slice(0, 16); + const idempotencyKey = `submitted:${channel}:${tag}:${evidence.remoteDigest}:${identityKey}`; appendEvent(ledger, { ...base, timestamp: timestampFor(idempotencyKey), type: 'channel_submitted', channel, idempotencyKey, payload: { artifactIds: artifactIds.scoop, - remoteIdentity: `${channel}:${tag}`, - remoteDigest: manifestSha256, + remoteIdentity: evidence.remoteIdentity, + remoteDigest: evidence.remoteDigest, + submission: evidence, }, }); } diff --git a/QUICKSTART.md b/QUICKSTART.md index 8949369..181088d 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -71,7 +71,7 @@ Follow the prompts to set: ## πŸ“‹ Available Commands -GuardScan provides **28 top-level commands** organized by category: +GuardScan provides **29 top-level commands** organized by category: ### Setup & Configuration @@ -82,6 +82,7 @@ guardscan status # Show provider, repo, and local config status guardscan reset # Clear local cache and config guardscan cache # Inspect or clear repository/global cache guardscan telemetry # Inspect, explicitly sync, or clear telemetry +guardscan capabilities # Inspect optional runtime capabilities and safe fallbacks ``` ### Security & Scanning (Offline-Capable, 100% FREE) diff --git a/README.md b/README.md index 646fae0..ead578d 100644 --- a/README.md +++ b/README.md @@ -213,20 +213,21 @@ OS-backed network sandbox and fails the affected checks if the platform sandbox | `guardscan rules` | Custom YAML-based rule engine | | `guardscan cache` | Inspect or clear local AI caches | | `guardscan telemetry` | Inspect, sync, or clear opt-in telemetry | +| `guardscan capabilities` | Inspect optional runtime capabilities and safe fallbacks | ### AI-Powered Commands (BYOK) | Command | Description | | --------------------------- | ------------------------------------ | -| `guardscan explain ` | Explain how code works | -| `guardscan review ` | Comprehensive AI code review | -| `guardscan commit` | Generate commit messages | -| `guardscan docs ` | Auto-generate documentation | -| `guardscan test-gen ` | Generate unit tests | -| `guardscan refactor ` | Get refactoring suggestions | -| `guardscan threat-model` | Security architecture analysis | -| `guardscan migrate` | Framework/language migration help | -| `guardscan chat` | Interactive Q&A about codebase (RAG) | +| `guardscan explain ` | Explain how code works | +| `guardscan review --file ` | Comprehensive AI code review | +| `guardscan commit` | Generate commit messages | +| `guardscan docs --type ` | Auto-generate documentation | +| `guardscan test-gen --file ` | Generate unit tests | +| `guardscan refactor --file ` | Get refactoring suggestions | +| `guardscan threat-model` | Security architecture analysis | +| `guardscan migrate` | Framework/language migration help | +| `guardscan chat` | Interactive Q&A about codebase (RAG) | --- @@ -332,7 +333,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ GuardScan CLI (Node.js/TypeScript) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ β€’ 28 Commands (security, vuln, run, test...) β”‚ β”‚ +β”‚ β”‚ β€’ 29 Commands (security, vuln, run, test...) β”‚ β”‚ β”‚ β”‚ β€’ 30 Core Modules (scanners, parsers, metrics) β”‚ β”‚ β”‚ β”‚ β€’ 9 AI Features (explain, review, test-gen, etc.) β”‚ β”‚ β”‚ β”‚ β€’ 7 Language Parsers (Python, Java, Go, Rust...) β”‚ β”‚ diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 368ef62..d0b9a12 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-07-13 + ### Added - Static-safe `guardscan scan` execution with explicit `--run-project-code` trust capability, scrubbed child environments, report execution-mode metadata, and optional OS-backed network isolation. @@ -16,6 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Manifest-bound stable-promotion approval evidence, atomic publication-ledger transitions, and deterministic native package adapter validation. - A fail-closed host-native Node.js SEA prototype builder and five-target non-publishable CI feasibility matrix. - Packed-artifact compatibility gates for npm, pnpm, Yarn Modern, Yarn Classic, and Bun. +- Native OSV-backed dependency vulnerability scanning with `guardscan vuln`, `cve`, and `audit` commands. +- Exact-version inventory support across JavaScript, Python, Go, Rust, Ruby, and Maven projects. +- Offline vulnerability coverage snapshots and explicit database status/update/clear commands. +- Stable finding fingerprints, deterministic scanner execution, comprehensive versioned JSON, and schema-valid SARIF. +- Explicit telemetry status, sync, and clear commands with a strict privacy allowlist. +- Repository-scoped and global cache clearing. +- Windows and expanded Node.js CI coverage plus installed-package smoke tests. +- A machine-readable `guardscan capabilities --json` diagnostic that exercises + optional token-counting and chart-rendering boundaries and reports observed + safe fallback behavior. ### Changed @@ -27,6 +39,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 locally until the user runs `guardscan telemetry sync`. - Configuration parsing is size-, depth-, node-, alias-, and schema-bounded; read-modify-write updates are lease-serialized across processes. - The supported Node.js runtime floor is now 22, with package smoke coverage on Node 22 and 24 across Linux, macOS, and Windows. +- Scanner failures and incomplete vulnerability coverage now fail closed with typed exit code `2` unless partial execution is explicitly allowed. +- CVE scanning is enabled by default when supported dependency manifests are present. +- Offline policy is enforced centrally for cloud AI, embeddings, update checks, telemetry, and advisory lookups. +- Environment-only cloud credentials and keyless Ollama/LM Studio configurations are supported. +- LM Studio endpoints are normalized to the documented `/v1` base path. +- Telemetry is opt-in and delivered only through an explicit sync command. +- License, SBOM, and vulnerability features share local dependency inventory. ### Removed @@ -46,33 +65,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Redirect-following provider and telemetry transports. - Deleted lint-baseline source files bypassing baseline review. - Yarn installation failing on an unresolvable stale optional tokenizer package. - -The strict ESLint gate remains a per-file non-regression ratchet; this release does not claim that the existing codebase is lint-clean. - -## [1.1.0] - 2026-07-13 - -### Added - -- Native OSV-backed dependency vulnerability scanning with `guardscan vuln`, `cve`, and `audit` commands. -- Exact-version inventory support across JavaScript, Python, Go, Rust, Ruby, and Maven projects. -- Offline vulnerability coverage snapshots and explicit database status/update/clear commands. -- Stable finding fingerprints, deterministic scanner execution, comprehensive versioned JSON, and schema-valid SARIF. -- Explicit telemetry status, sync, and clear commands with a strict privacy allowlist. -- Repository-scoped and global cache clearing. -- Windows and expanded Node.js CI coverage plus installed-package smoke tests. - -### Changed - -- Scanner failures and incomplete vulnerability coverage now fail closed with typed exit code `2` unless partial execution is explicitly allowed. -- CVE scanning is enabled by default when supported dependency manifests are present. -- Offline policy is enforced centrally for cloud AI, embeddings, update checks, telemetry, and advisory lookups. -- Environment-only cloud credentials and keyless Ollama/LM Studio configurations are supported. -- LM Studio endpoints are normalized to the documented `/v1` base path. -- Telemetry is opt-in and delivered only through an explicit sync command. -- License, SBOM, and vulnerability features share local dependency inventory. - -### Fixed - - Persisted metrics can no longer poison aggregation or crash `metrics show` through malformed optional fields. - Telemetry status, retention, and synchronization now quarantine invalid or future-dated events before use. - Legacy metrics and telemetry migrations validate completely before publishing any migrated event. @@ -89,6 +81,8 @@ The strict ESLint gate remains a per-file non-regression ratchet; this release d - Release publishing without every quality gate. - Windows `npm`/`npx` command resolution and shell-dependent child processes. +The strict ESLint gate remains a per-file non-regression ratchet; this release does not claim that the existing codebase is lint-clean. + ## [1.0.5] - 2025-12-09 ### Added diff --git a/cli/README.md b/cli/README.md index 6da7584..50cc782 100644 --- a/cli/README.md +++ b/cli/README.md @@ -185,20 +185,21 @@ OS-backed network sandbox and fails the affected checks if the platform sandbox | `guardscan rules` | Custom YAML-based rule engine | | `guardscan cache` | Inspect or clear local AI caches | | `guardscan telemetry` | Inspect, sync, or clear opt-in telemetry | +| `guardscan capabilities` | Inspect optional runtime capabilities and safe fallbacks | ### AI-Powered Commands (BYOK) | Command | Description | | --------------------------- | ------------------------------------ | -| `guardscan explain ` | Explain how code works | -| `guardscan review ` | Comprehensive AI code review | -| `guardscan commit` | Generate commit messages | -| `guardscan docs ` | Auto-generate documentation | -| `guardscan test-gen ` | Generate unit tests | -| `guardscan refactor ` | Get refactoring suggestions | -| `guardscan threat-model` | Security architecture analysis | -| `guardscan migrate` | Framework/language migration help | -| `guardscan chat` | Interactive Q&A about codebase (RAG) | +| `guardscan explain ` | Explain how code works | +| `guardscan review --file ` | Comprehensive AI code review | +| `guardscan commit` | Generate commit messages | +| `guardscan docs --type ` | Auto-generate documentation | +| `guardscan test-gen --file ` | Generate unit tests | +| `guardscan refactor --file ` | Get refactoring suggestions | +| `guardscan threat-model` | Security architecture analysis | +| `guardscan migrate` | Framework/language migration help | +| `guardscan chat` | Interactive Q&A about codebase (RAG) | --- @@ -307,7 +308,7 @@ GuardScan follows a **privacy-first, client-side architecture** where all code a β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ GuardScan CLI (Node.js/TypeScript) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ β€’ 28 Commands (security, vuln, run, test...) β”‚ β”‚ +β”‚ β”‚ β€’ 29 Commands (security, vuln, run, test...) β”‚ β”‚ β”‚ β”‚ β€’ 30 Core Modules (scanners, parsers, metrics) β”‚ β”‚ β”‚ β”‚ β€’ 9 AI Features (explain, review, test-gen, etc.) β”‚ β”‚ β”‚ β”‚ β€’ 7 Language Parsers (Python, Java, Go, Rust...) β”‚ β”‚ diff --git a/cli/__tests__/contracts/documentation-command-contracts.test.ts b/cli/__tests__/contracts/documentation-command-contracts.test.ts new file mode 100644 index 0000000..db23aef --- /dev/null +++ b/cli/__tests__/contracts/documentation-command-contracts.test.ts @@ -0,0 +1,116 @@ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const repositoryRoot = path.resolve(__dirname, '../../..'); +const cliPath = path.join(repositoryRoot, 'cli', 'dist', 'index.js'); + +function runHelp(command?: string): string { + const args = command ? [cliPath, command, '--help'] : [cliPath, '--help']; + const result = spawnSync(process.execPath, args, { + cwd: repositoryRoot, + encoding: 'utf8', + shell: false, + env: { + ...process.env, + GUARDSCAN_HOME: path.join(os.tmpdir(), 'guardscan-documentation-contract'), + GUARDSCAN_NO_TELEMETRY: 'true', + GUARDSCAN_OFFLINE: 'true', + NO_COLOR: '1', + FORCE_COLOR: '0', + }, + windowsHide: true, + }); + + if (result.error) throw result.error; + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + return result.stdout; +} + +function documentedQuickstartCommands(quickstart: string): string[] { + const inventory = quickstart.match( + /## πŸ“‹ Available Commands([\s\S]*?)\n---/ + )?.[1]; + if (!inventory) throw new Error('QUICKSTART.md has no bounded Available Commands section'); + return Array.from(inventory.matchAll(/^guardscan\s+([a-z][a-z-]*)/gm), match => match[1]); +} + +function actualTopLevelCommands(help: string): string[] { + const inventory = help.match(/^Commands:\s*$([\s\S]*)/m)?.[1]; + if (!inventory) throw new Error('guardscan --help has no Commands section'); + return Array.from( + inventory.matchAll(/^ ([a-z][a-z-]*)(?:\|[a-z-]+)?(?:\s|\[|<)/gm), + match => match[1] + ).filter(command => command !== 'help'); +} + +function documentedAcceptanceCommands(acceptance: string): string[] { + const inventory = acceptance.match( + /## Command acceptance matrix([\s\S]*?)(?=\n## )/ + )?.[1]; + if (!inventory) { + throw new Error('docs/FUNCTIONAL_ACCEPTANCE.md has no bounded command acceptance matrix'); + } + return Array.from( + inventory.matchAll(/^\| `guardscan ([a-z][a-z-]*)` \|/gm), + match => match[1] + ); +} + +describe('public command documentation contracts', () => { + const readme = fs.readFileSync(path.join(repositoryRoot, 'README.md'), 'utf8'); + const cliReadme = fs.readFileSync(path.join(repositoryRoot, 'cli', 'README.md'), 'utf8'); + const quickstart = fs.readFileSync(path.join(repositoryRoot, 'QUICKSTART.md'), 'utf8'); + const changelog = fs.readFileSync(path.join(repositoryRoot, 'cli', 'CHANGELOG.md'), 'utf8'); + + it('keeps all prepared 1.1.0 changes in one release section', () => { + const headings = changelog.match(/^## \[1\.1\.0\](?:\s|$)/gm) || []; + const release = changelog.match( + /^## \[1\.1\.0\][\s\S]*?(?=^## \[1\.0\.5\])/m + )?.[0]; + + expect(headings).toHaveLength(1); + expect(release).toContain('Static-safe `guardscan scan` execution'); + expect(release).toContain('Native OSV-backed dependency vulnerability scanning'); + expect(release).toContain('The GuardScan-hosted Cloudflare telemetry service'); + }); + + it('keeps the documented 29-command inventory identical to installed CLI help', () => { + const documented = documentedQuickstartCommands(quickstart); + const actual = actualTopLevelCommands(runHelp()); + + expect(new Set(documented).size).toBe(29); + expect(documented).toHaveLength(29); + expect(new Set(actual).size).toBe(29); + expect(actual).toHaveLength(29); + expect([...documented].sort()).toEqual([...actual].sort()); + }); + + it('classifies every installed command exactly once in the functional acceptance matrix', () => { + const acceptance = fs.readFileSync( + path.join(repositoryRoot, 'docs', 'FUNCTIONAL_ACCEPTANCE.md'), + 'utf8' + ); + const documented = documentedAcceptanceCommands(acceptance); + const actual = actualTopLevelCommands(runHelp()); + + expect(documented).toHaveLength(29); + expect(new Set(documented).size).toBe(29); + expect([...documented].sort()).toEqual([...actual].sort()); + }); + + it.each([ + ['review', 'guardscan review --file ', '--file ', 'guardscan review '], + ['docs', 'guardscan docs --type ', '--type ', 'guardscan docs '], + ['test-gen', 'guardscan test-gen --file ', '--file ', 'guardscan test-gen '], + ['refactor', 'guardscan refactor --file ', '--file ', 'guardscan refactor '], + ])('documents supported %s option syntax', (command, documented, helpOption, obsolete) => { + for (const document of [readme, cliReadme]) { + expect(document).toContain(`\`${documented}\``); + expect(document).not.toContain(`\`${obsolete}\``); + } + expect(runHelp(command)).toContain(helpOption); + }); +}); diff --git a/cli/__tests__/contracts/release-contracts.test.ts b/cli/__tests__/contracts/release-contracts.test.ts index df7c362..bcd2ff9 100644 --- a/cli/__tests__/contracts/release-contracts.test.ts +++ b/cli/__tests__/contracts/release-contracts.test.ts @@ -88,6 +88,22 @@ function makeManifest(): JsonDocument { sha256: 'd'.repeat(64), source, capabilities, + optionalCapabilities: { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: false, + mode: 'estimated', + sampleTokenCount: 7, + safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }, + }, platform: { os: 'linux', arch: 'x64', libc: 'glibc' }, archiveFormat: 'tar.gz', entrypoint: 'guardscan', @@ -338,6 +354,22 @@ describe('release contract schemas', () => { expect(validateManifest(unknownSignature)).toBe(false); }); + it('requires observed reduced-capability evidence on standalone artifacts', () => { + const missing = clone(makeManifest()) as { + artifacts: Array<{optionalCapabilities?: Record}>; + }; + delete missing.artifacts[1].optionalCapabilities; + expect(validateManifest(missing)).toBe(false); + + const inconsistent = clone(makeManifest()) as { + artifacts: Array<{ + optionalCapabilities?: {tokenCounting: {dependencyAvailable: boolean}}; + }>; + }; + inconsistent.artifacts[1].optionalCapabilities!.tokenCounting.dependencyAvailable = true; + expect(validateManifest(inconsistent)).toBe(false); + }); + it('rejects invalid state transitions, channel names, and unknown fields', () => { const badStatus = clone(makeState()) as { channels: Record; diff --git a/cli/__tests__/integration/rag-e2e.test.ts b/cli/__tests__/integration/rag-e2e.test.ts index 4bb92a6..d2638e1 100644 --- a/cli/__tests__/integration/rag-e2e.test.ts +++ b/cli/__tests__/integration/rag-e2e.test.ts @@ -1,403 +1,242 @@ /** - * rag-e2e.test.ts - End-to-End Integration Tests for RAG & Chat System + * Deterministic end-to-end coverage for the local RAG pipeline. * - * These tests verify the entire RAG pipeline works correctly from - * indexing to search to context building to chat responses. + * The provider doubles only the external embedding/chat boundaries. Real + * repository indexing, chunking, persistence, retrieval, context budgeting, + * and conversation state are exercised without network access or API keys. */ -import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; - -import { describe, expect, it, beforeEach, afterAll } from "@jest/globals"; - -describe("RAG System End-to-End", () => { - let testRepoDir: string; - let testCacheDir: string; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {afterEach, beforeEach, describe, expect, it} from '@jest/globals'; + +import {ChatbotEngine} from '../../src/core/chatbot-engine'; +import {CodebaseIndexer} from '../../src/core/codebase-indexer'; +import {EmbeddingChunker} from '../../src/core/embedding-chunker'; +import {EmbeddingIndexer} from '../../src/core/embedding-indexer'; +import {EmbeddingSearchEngine} from '../../src/core/embedding-search'; +import {FileBasedEmbeddingStore} from '../../src/core/embedding-store'; +import {EmbeddingProvider} from '../../src/core/embeddings'; +import {RAGContextBuilder} from '../../src/core/rag-context'; +import {AIMessage, AIProvider, AIResponse, ProviderCapabilities} from '../../src/providers/base'; + +class DeterministicEmbeddingProvider implements EmbeddingProvider { + getName(): string { return 'deterministic-local'; } + getDimensions(): number { return 4; } + getModel(): string { return 'keyword-v1'; } + estimateCost(): number { return 0; } + isAvailable(): boolean { return true; } + async testConnection(): Promise { return true; } + + async generateEmbedding(text: string): Promise { + const normalized = text.toLowerCase(); + const score = (terms: string[]) => terms.reduce( + (total, term) => total + (normalized.match(new RegExp(term, 'g')) || []).length, + 0 + ); + return [ + score(['auth', 'login', 'credential', 'password', 'verify']) + 0.01, + score(['user', 'account', 'profile', 'create']) + 0.01, + score(['database', 'lookup', 'save', 'persist']) + 0.01, + score(['readme', 'getting started', 'install', 'documentation']) + 0.01, + ]; + } - beforeEach(() => { - // Create a test repository with sample code - testRepoDir = path.join(os.tmpdir(), `test-repo-${Date.now()}`); - testCacheDir = path.join(os.tmpdir(), `test-cache-${Date.now()}`); + async generateBulkEmbeddings(texts: string[]): Promise { + return Promise.all(texts.map(text => this.generateEmbedding(text))); + } +} - fs.mkdirSync(testRepoDir, { recursive: true }); - fs.mkdirSync(path.join(testRepoDir, "src"), { recursive: true }); +class DeterministicChatProvider extends AIProvider { + readonly prompts: string[] = []; - // Create sample files - fs.writeFileSync( - path.join(testRepoDir, "src", "auth.ts"), - ` -export class AuthService { - /** - * Authenticate user with username and password - */ - async authenticate(username: string, password: string): Promise { - // Validate credentials - const user = await this.findUser(username); - if (!user) return false; - - return this.verifyPassword(user, password); + getCapabilities(): ProviderCapabilities { + return { + supportsChat: true, + supportsEmbeddings: false, + supportsStreaming: false, + maxContextTokens: 4096, + }; } - private async findUser(username: string) { - // Database lookup - return null; + async chat(messages: AIMessage[]): Promise { + const prompt = messages.map(message => message.content).join('\n'); + this.prompts.push(prompt); + return { + content: prompt.includes('AuthService') + ? 'Authentication is implemented by AuthService.' + : 'The indexed context was used.', + model: 'deterministic-chat-v1', + usage: {promptTokens: 20, completionTokens: 8, totalTokens: 28}, + }; } - private verifyPassword(user: any, password: string): boolean { - // Password verification logic - return true; + async *stream(): AsyncGenerator { + yield 'unused'; } -} - ` - ); - fs.writeFileSync( - path.join(testRepoDir, "src", "user.ts"), - ` -export interface User { - id: string; - username: string; - email: string; - createdAt: Date; + isAvailable(): boolean { return true; } + getName(): string { return 'deterministic-chat'; } + async testConnection(): Promise { return true; } } +describe('RAG system end to end', () => { + let repository: string; + let stateRoot: string; + let indexer: CodebaseIndexer; + let store: FileBasedEmbeddingStore; + let embeddingIndexer: EmbeddingIndexer; + let search: EmbeddingSearchEngine; + let contextBuilder: RAGContextBuilder; + + beforeEach(async () => { + repository = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-rag-repo-')); + stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-rag-state-')); + fs.mkdirSync(path.join(repository, 'src'), {recursive: true}); + fs.writeFileSync(path.join(repository, 'src', 'auth.ts'), ` +export class AuthService { + async authenticate(username: string, password: string): Promise { + const user = await this.findUser(username); + return user ? this.verifyPassword(user, password) : false; + } + private async findUser(username: string) { return { username }; } + private verifyPassword(_user: unknown, password: string) { return password.length > 0; } +} +`); + fs.writeFileSync(path.join(repository, 'src', 'user.ts'), ` +export interface User { id: string; username: string; email: string; } export class UserService { async createUser(username: string, email: string): Promise { - const user: User = { - id: this.generateId(), - username, - email, - createdAt: new Date(), - }; - - await this.saveUser(user); - return user; - } - - private generateId(): string { - return Math.random().toString(36).substring(7); - } - - private async saveUser(user: User): Promise { - // Save to database + return this.saveUser({id: 'user-1', username, email}); } + private async saveUser(user: User): Promise { return user; } } - ` - ); - - fs.writeFileSync( - path.join(testRepoDir, "README.md"), - ` +`); + fs.writeFileSync(path.join(repository, 'README.md'), ` # Test Project -This is a test project for the RAG system. - -## Features - -- User authentication -- User management -- Database integration - ## Getting Started -1. Install dependencies: \`npm install\` -2. Run the server: \`npm start\` - ` +Install dependencies with npm install. The project provides user authentication +and user management backed by a database. +`); + + const repoId = `rag-${path.basename(repository)}`; + const embeddingProvider = new DeterministicEmbeddingProvider(); + indexer = new CodebaseIndexer(repository, repoId); + store = new FileBasedEmbeddingStore(repoId, stateRoot); + embeddingIndexer = new EmbeddingIndexer( + indexer, + new EmbeddingChunker(indexer, repository), + embeddingProvider, + store, + repository ); + search = new EmbeddingSearchEngine(embeddingProvider, store); + contextBuilder = new RAGContextBuilder(search); }); - afterAll(() => { - // Cleanup - if (fs.existsSync(testRepoDir)) { - fs.rmSync(testRepoDir, { recursive: true, force: true }); - } - if (fs.existsSync(testCacheDir)) { - fs.rmSync(testCacheDir, { recursive: true, force: true }); - } + afterEach(async () => { + await indexer?.clearCache(); + fs.rmSync(repository, {recursive: true, force: true}); + fs.rmSync(stateRoot, {recursive: true, force: true}); }); - describe("Full RAG Workflow", () => { - it("should complete the full workflow: index -> search -> chat", async () => { - // This test demonstrates the complete RAG pipeline - // In a real implementation, you would: - // - // 1. Initialize components - // 2. Index the codebase - // 3. Perform semantic search - // 4. Build RAG context - // 5. Generate chat response - // - // For now, this is a placeholder test structure - - expect(fs.existsSync(testRepoDir)).toBe(true); - expect(fs.existsSync(path.join(testRepoDir, "src", "auth.ts"))).toBe( - true - ); - expect(fs.existsSync(path.join(testRepoDir, "src", "user.ts"))).toBe( - true - ); - }); - - it("should find relevant code for authentication queries", async () => { - // This would test that when asking "How does authentication work?", - // the system finds the AuthService class and related code - - // Placeholder: In real implementation, would: - // - Index the codebase - // - Search for "authentication" - // - Verify AuthService is in top results - // - Verify relevance scores are high - - expect(true).toBe(true); - }); - - it("should find relevant code for user management queries", async () => { - // This would test that when asking "How do I create a user?", - // the system finds the UserService.createUser method - - // Placeholder: In real implementation, would: - // - Index the codebase - // - Search for "create user" - // - Verify UserService.createUser is in top results - // - Verify the context includes the User interface - - expect(true).toBe(true); - }); - - it("should combine code and documentation in context", async () => { - // This would test that RAG context includes both: - // - Code snippets (functions, classes) - // - Documentation (README content) - - // Placeholder: In real implementation, would: - // - Build RAG context for "Getting Started" - // - Verify README.md content is included - // - Verify related code is also included - // - Verify token budget is respected - - expect(true).toBe(true); - }); - - it("should handle incremental updates", async () => { - // This would test that when files change: - // - Only changed files are re-indexed - // - Existing embeddings are preserved - // - Search results are updated - - // Placeholder: In real implementation, would: - // - Initial indexing - // - Modify one file - // - Incremental update - // - Verify only 1 file was re-processed - // - Verify search results reflect changes - - expect(true).toBe(true); - }); - - it("should maintain chat context across turns", async () => { - // This would test multi-turn conversations: - // - First question: "What authentication methods are available?" - // - Follow-up: "How do I use them?" - // - The system should understand "them" refers to authentication methods - - // Placeholder: In real implementation, would: - // - Create chat session - // - Ask first question - // - Ask follow-up with pronoun reference - // - Verify context maintains conversation history - // - Verify AI understands the reference - - expect(true).toBe(true); - }); + it('indexes, persists, retrieves, builds context, and answers a chat turn', async () => { + const indexed = await embeddingIndexer.indexCodebase({ + incremental: false, + showProgress: false, + validateEmbeddings: true, + batchSize: 10, + }); + expect(indexed.success).toBe(true); + expect(indexed.stats.filesAnalyzed).toBe(2); + expect(indexed.stats.embeddingsGenerated).toBeGreaterThan(2); + expect(await store.exists()).toBe(true); + + const auth = await search.search('How does user login authentication verify a password?', { + k: 5, + minSimilarity: 0.2, + }); + expect(auth.results[0].embedding.source).toContain('auth.ts'); + expect(auth.results.some(result => result.embedding.content.includes('AuthService'))).toBe(true); + + const context = await contextBuilder.buildContext( + 'Explain authentication and the getting started installation', + [], + {maxTokens: 1200, codeWeight: 0.6, docsWeight: 0.3, historyWeight: 0.1} + ); + expect(context.relevantCode.some(snippet => snippet.source.includes('auth.ts'))).toBe(true); + expect(context.relevantDocs.some(snippet => snippet.source.endsWith('README.md'))).toBe(true); + expect(context.tokensUsed).toBeLessThanOrEqual(context.tokenBudget); + + const chatProvider = new DeterministicChatProvider(); + const chatbot = new ChatbotEngine( + chatProvider, + contextBuilder, + search, + indexer, + 'test-repo', + repository + ); + const session = await chatbot.createSession({projectName: 'Test Project'}); + const response = await chatbot.chat(session.id, 'How does authentication work?'); + expect(response.message.content).toContain('AuthService'); + expect(response.message.metadata?.relevantFiles).toContain('src/auth.ts'); + expect(response.stats.relevantSnippets).toBeGreaterThan(0); }); - describe("Performance and Scalability", () => { - it("should handle large codebases efficiently", async () => { - // This would test performance with many files - - // Placeholder: In real implementation, would: - // - Create 100+ sample files - // - Measure indexing time - // - Measure search time - // - Verify performance is acceptable - - expect(true).toBe(true); - }); - - it("should cache embeddings effectively", async () => { - // This would test that: - // - Embeddings are saved to disk - // - Subsequent loads use cache - // - Cache invalidation works - - // Placeholder: In real implementation, would: - // - Index codebase - // - Verify embeddings saved - // - Load embeddings (should be fast) - // - Measure cache hit rate - - expect(true).toBe(true); - }); - - it("should respect token budgets", async () => { - // This would test that context building: - // - Respects maxTokens limit - // - Allocates tokens correctly (60% code, 20% docs, 20% history) - // - Truncates content when necessary - - // Placeholder: In real implementation, would: - // - Build context with 1000 token limit - // - Verify total tokens <= 1000 - // - Verify allocation percentages are correct + it('preserves unchanged embeddings and refreshes changed source incrementally', async () => { + await embeddingIndexer.indexCodebase({incremental: false, showProgress: false}); + fs.appendFileSync( + path.join(repository, 'src', 'auth.ts'), + '\nexport const resetPassword = (user: string) => `reset:${user}`;\n' + ); + await indexer.clearCache(); - expect(true).toBe(true); + const updated = await embeddingIndexer.indexCodebase({ + incremental: true, + showProgress: false, }); + expect(updated.success).toBe(true); + expect(updated.stats.chunksCached).toBeGreaterThan(0); + expect(updated.stats.embeddingsGenerated).toBeGreaterThan(0); + const stored = await store.loadEmbeddings(); + expect(stored.some(embedding => embedding.content.includes('resetPassword'))).toBe(true); }); - describe("Error Handling and Edge Cases", () => { - it("should handle empty repositories gracefully", async () => { - const emptyDir = path.join(os.tmpdir(), `empty-repo-${Date.now()}`); - fs.mkdirSync(emptyDir, { recursive: true }); - - try { - // Should not crash when indexing empty repo - expect(fs.existsSync(emptyDir)).toBe(true); - - // Placeholder: In real implementation, would: - // - Attempt to index empty repo - // - Verify no errors thrown - // - Verify graceful handling - } finally { - fs.rmSync(emptyDir, { recursive: true, force: true }); - } - }); - - it("should handle files with parsing errors", async () => { - // Create file with syntax errors - const invalidFile = path.join(testRepoDir, "invalid.ts"); - fs.writeFileSync( - invalidFile, - "function broken( { this is invalid syntax" - ); - - try { - // Should skip invalid files but continue indexing - - // Placeholder: In real implementation, would: - // - Attempt to index repo with invalid file - // - Verify indexing continues for valid files - // - Verify error is logged but not fatal - - expect(true).toBe(true); - } finally { - if (fs.existsSync(invalidFile)) { - fs.unlinkSync(invalidFile); - } - } - }); - - it("should handle very long files", async () => { - // Create file with 10,000 lines - const longFile = path.join(testRepoDir, "long.ts"); - const longContent = 'console.log("line");\n'.repeat(10000); - fs.writeFileSync(longFile, longContent); - - try { - // Should chunk large files appropriately - - // Placeholder: In real implementation, would: - // - Index repo with very long file - // - Verify file is chunked into multiple embeddings - // - Verify no memory issues - - expect(true).toBe(true); - } finally { - if (fs.existsSync(longFile)) { - fs.unlinkSync(longFile); - } - } - }); - - it("should handle special characters in code", async () => { - // Create file with unicode, emojis, special chars - const specialFile = path.join(testRepoDir, "special.ts"); - fs.writeFileSync( - specialFile, - ` -// Comment with emoji πŸš€ -const greeting = "Hello δΈ–η•Œ"; -const symbol = "©️ 2024"; - ` - ); - - try { - // Should handle special characters correctly - - // Placeholder: In real implementation, would: - // - Index file with special characters - // - Verify embeddings generated correctly - // - Verify search works with special chars - - expect(true).toBe(true); - } finally { - if (fs.existsSync(specialFile)) { - fs.unlinkSync(specialFile); - } - } - }); + it('combines recent conversation history without exceeding its token budget', async () => { + await embeddingIndexer.indexCodebase({incremental: false, showProgress: false}); + const history = [ + {role: 'user' as const, content: 'Which service authenticates users?', timestamp: new Date()}, + {role: 'assistant' as const, content: 'AuthService authenticates users.', timestamp: new Date()}, + ]; + const context = await contextBuilder.buildContext('How do I use it?', history, { + maxTokens: 300, + codeWeight: 0.5, + docsWeight: 0.2, + historyWeight: 0.3, + }); + expect(context.conversationHistory).toEqual(history); + expect(context.tokensUsed).toBeLessThanOrEqual(300); + expect(contextBuilder.formatContextForPrompt(context)).toContain('Recent Conversation'); }); - describe("Search Quality", () => { - it("should rank exact matches highest", async () => { - // When searching for "AuthService", - // the AuthService class should be ranked first - - // Placeholder: In real implementation, would: - // - Search for "AuthService" - // - Verify first result is AuthService class - // - Verify similarity score is high - - expect(true).toBe(true); - }); - - it("should find semantically similar code", async () => { - // When searching for "login user", - // should find authenticate() method even though - // it doesn't contain the word "login" - - // Placeholder: In real implementation, would: - // - Search for "login user" - // - Verify authenticate() is in results - // - Verify semantic similarity works - - expect(true).toBe(true); - }); - - it("should apply diversity to results", async () => { - // Results should not all come from the same file - - // Placeholder: In real implementation, would: - // - Search for broad term like "user" - // - Verify results come from multiple files - // - Verify diversity threshold is applied + it('continues indexing valid files when another source file cannot be parsed', async () => { + fs.writeFileSync(path.join(repository, 'src', 'invalid.ts'), 'function broken( {'); + const index = await indexer.buildIndex(); + expect(index.files.has('src/auth.ts')).toBe(true); + expect(index.files.has('src/user.ts')).toBe(true); + }); - expect(true).toBe(true); - }); + it('handles an empty repository without creating fake embeddings', async () => { + fs.rmSync(path.join(repository, 'src'), {recursive: true, force: true}); + fs.unlinkSync(path.join(repository, 'README.md')); + const result = await embeddingIndexer.indexCodebase({showProgress: false}); + expect(result).toMatchObject({success: true}); + expect(result.stats.filesAnalyzed).toBe(0); + expect(result.stats.totalChunks).toBe(0); + expect(await store.count()).toBe(0); }); }); - -/** - * NOTE: The tests above are placeholder structures demonstrating - * what a comprehensive E2E test suite should cover. - * - * To make these tests fully functional, you would need to: - * - * 1. Use real (or mock) embedding providers - * 2. Initialize the full RAG pipeline - * 3. Perform actual indexing and search operations - * 4. Measure and verify results - * - * The current implementation provides the structure and test cases - * without requiring API keys or external services for CI/CD. - */ diff --git a/cli/__tests__/scripts/release-credential-monitor.test.ts b/cli/__tests__/scripts/release-credential-monitor.test.ts new file mode 100644 index 0000000..09203dc --- /dev/null +++ b/cli/__tests__/scripts/release-credential-monitor.test.ts @@ -0,0 +1,126 @@ +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; + +const workflowPath = path.resolve( + __dirname, + '../../../.github/workflows/release-credential-health.yml' +); + +type Workflow = { + on: Record; + permissions: Record; + jobs: Record; + steps?: Array<{name?: string; uses?: string; run?: string}>; + }>; +}; + +function loadWorkflow(): {source: string; workflow: Workflow} { + const source = fs.readFileSync(workflowPath, 'utf8'); + return {source, workflow: yaml.load(source) as Workflow}; +} + +describe('release credential and provider health workflow', () => { + it('is scheduled and manually runnable but inert until explicitly enabled', () => { + const {workflow} = loadWorkflow(); + expect(workflow.on).toHaveProperty('schedule'); + expect(workflow.on).toHaveProperty('workflow_dispatch'); + expect(workflow.on).not.toHaveProperty('push'); + expect(workflow.on).not.toHaveProperty('pull_request'); + + for (const job of Object.values(workflow.jobs)) { + expect(job.if).toContain("vars.RELEASE_CREDENTIAL_MONITOR_ENABLED == 'true'"); + expect(job.if).toContain("vars.RELEASE_AUTOMATION_ENABLED == 'true'"); + } + }); + + it('uses least privilege, protected environments, and only commit-pinned actions', () => { + const {source, workflow} = loadWorkflow(); + expect(workflow.permissions).toEqual({contents: 'read'}); + expect(workflow.jobs.apple.environment).toBe('apple-notarization'); + expect(workflow.jobs.azure.environment).toBe('windows-signing'); + expect(workflow.jobs.winget.environment).toBe('winget'); + expect(workflow.jobs.chocolatey.environment).toBe('chocolatey'); + expect(workflow.jobs.azure.permissions).toMatchObject({ + contents: 'read', + 'id-token': 'write', + }); + + const uses = [...source.matchAll(/^\s*uses:\s*([^\s#]+)/gm)].map(match => match[1]); + expect(uses.length).toBeGreaterThan(0); + for (const action of uses) { + expect(action).toMatch(/^[^@\s]+@[0-9a-f]{40}$/); + } + + for (const job of Object.values(workflow.jobs)) { + expect(job['timeout-minutes']).toBeGreaterThan(0); + expect(job['timeout-minutes']).toBeLessThanOrEqual(15); + } + }); + + it('covers provider authentication and explicitly records unsupported states as unknown', () => { + const {source, workflow} = loadWorkflow(); + expect(Object.keys(workflow.jobs)).toEqual(expect.arrayContaining([ + 'github-catalog', + 'trusted-publishers', + 'apple', + 'azure', + 'winget', + 'chocolatey', + 'report', + ])); + expect(source).toContain('guardscan.credential-health.v1'); + expect(source).toContain('ntanwir10/homebrew-tap'); + expect(source).toContain('permission-workflows: write'); + expect(source).toContain("run('xcrun', ["); + expect(source).toContain("'notarytool', 'history'"); + expect(source).toContain('APPLE_CERTIFICATE_P12'); + expect(source).toContain('azure/login@'); + expect(source).toContain('AZURE_SIGNING_PROFILE'); + expect(source).toContain('github-authentication-token-expiration'); + expect(source).toContain('WINGET_GITHUB_TOKEN'); + expect(source).toContain('CHOCO_API_KEY'); + expect(source).toContain("claStatus: {status: 'unknown'"); + expect(source).toContain("expiry: {status: 'unknown'"); + expect(source).toContain("signingProfile = {status: 'unknown'"); + expect(source).toContain("apiKeyAuthentication: {status: 'unknown'"); + }); + + it('is non-publishing and does not contain secret-printing constructs', () => { + const {source} = loadWorkflow(); + for (const forbidden of [ + 'npm publish', + 'npm dist-tag', + 'twine upload', + 'gh release create', + 'git push', + 'wingetcreate submit', + 'choco push', + 'repository_dispatch', + 'set -x', + ]) { + expect(source.toLowerCase()).not.toContain(forbidden); + } + expect(source).not.toMatch(/echo[^\n]*(RELEASE_APP_PRIVATE_KEY|APPLE_CERTIFICATE_P12|APPLE_NOTARY_PRIVATE_KEY|WINGET_GITHUB_TOKEN|CHOCO_API_KEY)/i); + expect(source).not.toMatch(/Write-(Host|Output)[^\n]*(WINGET_GITHUB_TOKEN|CHOCO_API_KEY)/i); + }); + + it('keeps every embedded Node evidence generator syntactically valid', () => { + const {workflow} = loadWorkflow(); + let generators = 0; + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps || []) { + if (!step.run) continue; + for (const match of step.run.matchAll(/node - <<'NODE'\n([\s\S]*?)\nNODE/g)) { + expect(() => new Function('require', 'process', 'console', match[1])).not.toThrow(); + generators += 1; + } + } + } + expect(generators).toBeGreaterThanOrEqual(7); + }); +}); diff --git a/cli/__tests__/scripts/release-tool.test.ts b/cli/__tests__/scripts/release-tool.test.ts index d44280e..f9609c1 100644 --- a/cli/__tests__/scripts/release-tool.test.ts +++ b/cli/__tests__/scripts/release-tool.test.ts @@ -9,6 +9,7 @@ const { createInitialState, createPlan, prepareRelease, + releaseTrainChannels, summarizeState, validateSource, } = require('../../scripts/release/lib') as { @@ -31,6 +32,10 @@ const { source: Record, options: Record ) => {created: boolean; outputDir: string}; + releaseTrainChannels: ( + channel: 'rc' | 'stable', + options?: {homebrewCoreEnabled?: boolean} + ) => string[]; summarizeState: (state: Record) => Record; validateSource: (options: Record) => Record; }; @@ -129,6 +134,24 @@ describe('release source validation', () => { expect(() => validateSource({packageRoot, repositoryRoot: root, commit: 'not-a-sha'})) .toThrow(/commit must be a 40-character lowercase git SHA/); }); + + it('rejects split or duplicated stable release notes', () => { + fs.writeFileSync( + path.join(packageRoot, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n\n### Added\n\n- A 1.2.3 feature.\n\n' + + '## [1.2.3] - 2026-07-20\n\n- Older notes.\n' + ); + expect(() => validateSource({packageRoot, repositoryRoot: root, commit: COMMIT})) + .toThrow(/Unreleased section must be empty for stable release 1\.2\.3/); + + fs.writeFileSync( + path.join(packageRoot, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n\n## [1.2.3] - 2026-07-20\n\n' + + '## [1.2.3] - 2026-07-21\n' + ); + expect(() => validateSource({packageRoot, repositoryRoot: root, commit: COMMIT})) + .toThrow(/exactly one release section for 1\.2\.3/); + }); }); describe('release planning and state summaries', () => { @@ -202,6 +225,20 @@ describe('release planning and state summaries', () => { }); }); + it('selects Homebrew Core only when optional stable submission is enabled', () => { + expect(releaseTrainChannels('rc')).toEqual([ + 'npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi', + ]); + expect(releaseTrainChannels('stable')).toEqual([ + 'npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi', + 'winget', 'chocolatey', + ]); + expect(releaseTrainChannels('stable', {homebrewCoreEnabled: true})).toEqual([ + 'npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi', + 'homebrew-core', 'winget', 'chocolatey', + ]); + }); + it('materializes catalog publication evidence and its immutable remote identity', () => { const first = createEvent({ version: '1.2.3', diff --git a/cli/__tests__/scripts/release-train.test.ts b/cli/__tests__/scripts/release-train.test.ts index bbb61ce..40cde45 100644 --- a/cli/__tests__/scripts/release-train.test.ts +++ b/cli/__tests__/scripts/release-train.test.ts @@ -44,9 +44,21 @@ const { planRollback, reconcileRelease, } = require('../../scripts/release/reconcile') as { - planRollback: (state: Record, knownGood?: string) => Record; + planRollback: ( + state: Record, + knownGoodVersion?: string, + knownGoodCommit?: string + ) => Record; reconcileRelease: (state: Record) => Record; }; +const { + prepareForwardFixSource, +} = require('../../scripts/release/recovery-source') as { + prepareForwardFixSource: ( + repositoryRoot: string, + input: Record + ) => Record; +}; const { createReleaseManifest, } = require('../../scripts/release/manifest') as { @@ -126,6 +138,22 @@ function standalone( chartRendering: false, accurateTokenCounting: false, }, + optionalCapabilities: { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: false, + mode: 'estimated', + sampleTokenCount: 7, + safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }, + }, platform: {os: osName, arch, ...(libc ? {libc} : {})}, archiveFormat: osName === 'windows' ? 'zip' : 'tar.gz', entrypoint, @@ -167,6 +195,55 @@ function wheel(native: Record, digest: string): Record } describe('append-only release train', () => { + it('prepares deterministic forward-fix source from the exact known-good tree', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-forward-fix-')); + const cli = path.join(root, 'cli'); + fs.mkdirSync(cli); + fs.writeFileSync(path.join(cli, 'package.json'), `${JSON.stringify({ + name: 'guardscan', + version: '1.1.9', + }, null, 2)}\n`); + fs.writeFileSync(path.join(cli, 'package-lock.json'), `${JSON.stringify({ + name: 'guardscan', + version: '1.1.9', + lockfileVersion: 3, + packages: {'': {name: 'guardscan', version: '1.1.9'}}, + }, null, 2)}\n`); + fs.writeFileSync(path.join(cli, 'CHANGELOG.md'), [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [1.1.9]', + '', + '- Known good.', + '', + ].join('\n')); + try { + const input = { + knownGoodVersion: '1.1.9', + defectiveVersion: '1.2.0', + forwardFixVersion: '1.2.1', + }; + const first = prepareForwardFixSource(root, input); + const second = prepareForwardFixSource(root, input); + expect(first).toMatchObject({ + changed: true, + version: '1.2.1', + files: ['cli/CHANGELOG.md', 'cli/package-lock.json', 'cli/package.json'], + }); + expect(second).toMatchObject({changed: false, version: '1.2.1'}); + expect(JSON.parse(fs.readFileSync(path.join(cli, 'package.json'), 'utf8')).version) + .toBe('1.2.1'); + expect(JSON.parse(fs.readFileSync(path.join(cli, 'package-lock.json'), 'utf8'))) + .toMatchObject({version: '1.2.1', packages: {'': {version: '1.2.1'}}}); + expect(fs.readFileSync(path.join(cli, 'CHANGELOG.md'), 'utf8')) + .toContain('## [1.2.1]\n\n### Fixed\n\n- Restore verified v1.1.9 source'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + it('appends, replays, retries idempotently, and models rollback without backward mutation', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-ledger-')); const ledger = path.join(root, 'v1.2.0-rc.1.jsonl'); @@ -193,9 +270,22 @@ describe('append-only release train', () => { payload: {}, })); appendEvent(ledger, eventInput('rollback_started', 3, { - payload: {knownGoodVersion: null, forwardFixVersion: '1.2.1'}, + payload: { + knownGoodVersion: '1.1.9', + knownGoodCommit: 'b'.repeat(40), + forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + }, })); - appendEvent(ledger, eventInput('superseded', 4, { + appendEvent(ledger, eventInput('action_required', 4, { + channel: 'npm', + payload: { + action: 'deprecate-and-forward-fix', + authority: 'npm-maintainer', + reason: 'GitHub OIDC trusted publishing cannot deprecate an existing npm version', + }, + })); + appendEvent(ledger, eventInput('superseded', 5, { channel: 'npm', payload: published.payload, })); @@ -203,7 +293,14 @@ describe('append-only release train', () => { const state = materializeReleaseState(readEvents(ledger)); expect(state).toMatchObject({ schemaVersion: 'guardscan.release-state.v2', - lastSequence: 5, + lastSequence: 6, + actionRequired: [{ + channel: 'npm', + action: 'deprecate-and-forward-fix', + authority: 'npm-maintainer', + reason: 'GitHub OIDC trusted publishing cannot deprecate an existing npm version', + requestedAt: '2026-07-20T00:04:00.000Z', + }], channels: { npm: { status: 'superseded', @@ -219,9 +316,37 @@ describe('append-only release train', () => { {channel: 'winget', currentStatus: 'planned', action: 'submit', required: true}, ]), }); - expect(planRollback(state)).toMatchObject({ + const rollbackInput = { + ...state, + channels: { + ...state.channels, + npm: {...state.channels.npm, status: 'verified'}, + }, + }; + expect(planRollback(rollbackInput, '1.1.9', 'b'.repeat(40))).toMatchObject({ + schemaVersion: 'guardscan.rollback-plan.v1', + knownGood: { + version: '1.1.9', + tag: 'v1.1.9', + commit: 'b'.repeat(40), + }, forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + repositoryActions: expect.arrayContaining([ + expect.objectContaining({id: 'deactivate-train'}), + expect.objectContaining({id: 'forward-fix-pr'}), + expect.objectContaining({id: 'shared-catalog-rollback'}), + ]), + actions: expect.arrayContaining([ + expect.objectContaining({ + channel: 'npm', + automation: 'external-action-required', + authority: 'npm-maintainer', + }), + ]), }); + expect(() => planRollback(rollbackInput)).toThrow(/verified known-good version is required/); + expect(() => planRollback(rollbackInput, '1.1.9')).toThrow(/known-good commit is required/); } finally { fs.rmSync(root, {recursive: true, force: true}); } @@ -238,6 +363,22 @@ describe('append-only release train', () => { fs.rmSync(root, {recursive: true, force: true}); } }); + + it('rejects malformed external recovery authority events', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-ledger-action-')); + const ledger = path.join(root, 'ledger.jsonl'); + try { + appendEvent(ledger, eventInput('train_started', 0, { + payload: {channels: ['npm']}, + })); + expect(() => appendEvent(ledger, eventInput('action_required', 1, { + channel: 'npm', + payload: {action: 'deprecate-and-forward-fix'}, + }))).toThrow(/action_required payload/); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); }); describe('promotion policy and remote idempotency', () => { @@ -368,6 +509,12 @@ describe('deterministic artifacts and manifest aggregation', () => { artifacts, }); expect(manifest.artifacts).toHaveLength(10); + expect(manifest.artifacts + .filter((artifact: Record) => artifact.kind === 'standalone') + .every((artifact: Record) => ( + artifact.optionalCapabilities?.schemaVersion + === 'guardscan.runtime-capabilities.v1' + ))).toBe(true); const mismatched = JSON.parse(JSON.stringify(artifacts)); mismatched[5].embeddedExecutableSha256 = 'f'.repeat(64); expect(() => createReleaseManifest(source, { diff --git a/cli/__tests__/scripts/release-workflows.test.ts b/cli/__tests__/scripts/release-workflows.test.ts index e1e6330..bbe0e5f 100644 --- a/cli/__tests__/scripts/release-workflows.test.ts +++ b/cli/__tests__/scripts/release-workflows.test.ts @@ -117,7 +117,8 @@ describe('zero-touch release workflow contracts', () => { expect(train).toContain('channel-preview/v$RELEASE_VERSION'); expect(train).toContain("type: 'channel_published'"); expect(train).toContain("type: 'channel_submitted'"); - expect(train).toContain("['homebrew-core', 'winget', 'chocolatey']"); + expect(train).toContain("releaseTrainChannels(process.env.RELEASE_CHANNEL)"); + expect(train).not.toContain("? ['homebrew-core', 'winget', 'chocolatey']"); expect(train).toContain('cannot start or promote while a stable train remains incomplete'); expect(canary).toContain("train.channel !== 'stable'"); expect(canary).toContain('reconcileRelease(materializeReleaseState(readEvents(ledger))).complete'); @@ -130,6 +131,101 @@ describe('zero-touch release workflow contracts', () => { ); }); + it('fails closed and persists an idempotent repository-side rollback recovery', () => { + const train = workflowSource('release-train.yml'); + expect(train).toContain('repositories: GuardScan,homebrew-tap'); + expect(train).toContain("schemaVersion: 'guardscan.rollback-plan.v1'"); + expect(train).toContain('verified known-good release is required'); + expect(train).toContain('known-good release ledger is incomplete'); + expect(train).toContain('known-good manifest digest does not match its protected ledger'); + expect(train).toContain('release/forward-fix-v${FORWARD_FIX_VERSION}-from-v${KNOWN_GOOD_VERSION}'); + expect(train).toContain('Rollback GuardScan v${{ inputs.version }} catalog to v${{ inputs.known_good }}'); + expect(train).toContain('gh pr create "${FORWARD_FIX_PR_ARGS[@]}"'); + expect(train).toContain('gh pr merge --repo ntanwir10/homebrew-tap'); + expect(train).toContain("event.type === 'action_required'"); + expect(train).toContain('active.trains = active.trains.filter'); + expect(train).toContain('rollback-plan-v${{ inputs.version }}'); + expect(train).toContain('rollback-plan.json'); + expect(train).toContain('rollback-evidence.json'); + expect(train).not.toContain('NPM_TOKEN'); + expect(train).not.toContain('PYPI_TOKEN'); + }); + + it('uses default-branch canary tooling while every matrix entry verifies its own version', () => { + const canary = workflowSource('release-canary.yml'); + expect(canary).not.toContain('implementation_ref'); + expect(canary).not.toContain('trains[0]'); + expect(canary).toContain('ref: ${{ github.event.repository.default_branch }}'); + expect(canary).toContain('VERSION: ${{ matrix.train.version }}'); + expect(canary).toContain('version = \'${{ matrix.train.version }}\''); + expect(canary).toContain('assert_version guardscan --version'); + expect(canary).toContain('test "$ACTUAL_VERSION" = "$VERSION"'); + expect(canary).toContain('test "$(guardscan --version | tr -d \'\\r\')" = "$VERSION"'); + expect(canary).toContain('test "$("$PIPX_GUARDSCAN" --version | tr -d \'\\r\')" = "$VERSION"'); + expect(canary).toContain('$installedVersion -ne $env:VERSION'); + expect(canary).toContain('const expectedTargetCounts = {'); + expect(canary).toContain('resolved.length === expectedCount'); + expect(canary).toContain("type: 'channel_failed'"); + expect(canary).toContain("error: 'one or more current public canary targets failed'"); + expect(canary).toContain('const versionAggregateTimestamp = versionReports'); + expect(canary).not.toContain('{remoteIdentity: `${channel}:${version}`}'); + expect(canary).toContain("if: inputs.version == '' || vars.RELEASE_AUTOMATION_ENABLED == 'true'"); + expect(canary).toMatch( + /record:\n[\s\S]*?if: >-\n\s+always\(\)\n\s+&& vars\.RELEASE_AUTOMATION_ENABLED == 'true'/ + ); + }); + + it('keeps undiscovered moderated packages pending and fails discovered lifecycle errors', () => { + const canary = workflowSource('release-canary.yml'); + expect(canary).toContain('$discovered = $false'); + expect(canary).toContain('$discovered = $true'); + expect(canary).toContain("if ($discovered) { $report.status = 'failed' }"); + expect(canary).not.toContain("throw 'WinGet package is not public yet'"); + expect(canary).not.toContain("throw 'Chocolatey package is not public yet'"); + expect(canary).not.toContain("-notmatch 'not found|No package found|Unable to find'"); + }); + + it('tests every TestPyPI wheel natively before production PyPI publication', () => { + const source = workflowSource('release-publish.yml'); + const workflow = yaml.load(source) as { + jobs: Record}}; + }>; + }; + const lifecycle = workflow.jobs['pypi-test-lifecycle']; + expect(lifecycle.needs).toEqual(['pypi-test']); + expect(lifecycle['runs-on']).toBe('${{ matrix.target.runner }}'); + expect(lifecycle.strategy?.matrix?.target).toEqual([ + {id: 'linux-x64-glibc', runner: 'ubuntu-24.04'}, + {id: 'linux-arm64-glibc', runner: 'ubuntu-24.04-arm'}, + {id: 'darwin-arm64', runner: 'macos-15'}, + {id: 'darwin-x64', runner: 'macos-15-intel'}, + {id: 'windows-x64', runner: 'windows-2025'}, + ]); + expect(workflow.jobs.pypi.needs).toEqual(['pypi-test-lifecycle']); + expect(source).toContain('Test TestPyPI wheel through pip and pipx on ${{ matrix.target.id }}'); + expect(source).toContain('python -m pipx environment --value PIPX_BIN_DIR'); + expect(source).toContain('exercise_guardscan guardscan pip'); + expect(source).toContain('exercise_guardscan "$GUARDSCAN" pipx'); + expect(source).toContain('"$executable" --help'); + expect(source).toContain('--no-telemetry scan --offline --no-cve --skip-tests --skip-ai'); + expect(source).toContain("scan.get('schemaVersion') != 'guardscan.scan.v1'"); + }); + + it('validates and readies the exact same-repository main release PR before candidate tagging', () => { + const train = workflowSource('release-train.yml'); + expect(train).toContain("pr.state !== 'open'"); + expect(train).toContain("pr.base?.ref !== 'main'"); + expect(train).toContain('pr.head?.repo?.full_name?.toLowerCase()'); + expect(train).toContain('process.env.GITHUB_REPOSITORY.toLowerCase()'); + expect(train).toContain('gh pr ready "${{ inputs.release_pr }}"'); + expect(train.indexOf('gh pr ready "${{ inputs.release_pr }}"')) + .toBeLessThan(train.indexOf('Derive bot-owned RC commit from exact stable PR head')); + expect(train).toContain('--match-head-commit "${{ steps.source.outputs.head_sha }}"'); + }); + it('builds signed artifacts and publishes through isolated provider environments', () => { const build = workflowSource('release-build.yml'); const publish = workflowSource('release-publish.yml'); @@ -149,9 +245,15 @@ describe('zero-touch release workflow contracts', () => { expect(build).toContain('cosign sign-blob --yes'); expect(build).toContain('xcrun notarytool submit'); expect(build).toContain('xcrun stapler staple'); + expect(build).toContain('security find-identity -v -p codesigning "$KEYCHAIN" | grep -F "$IDENTITY"'); + expect(build).toContain("if: always() && matrix.os == 'darwin'"); + expect(build).toContain('security delete-keychain "$RUNNER_TEMP/guardscan-signing.keychain-db" || true'); + expect(build).toContain('rm -f "$RUNNER_TEMP/certificate.p12" "$RUNNER_TEMP/AuthKey.p8"'); expect(build).toContain('Azure/artifact-signing-action@'); expect(build).toContain('actions/attest-build-provenance@'); expect(build).toContain('release-manifest.json'); + expect(build).toContain('npm run test:package'); + expect(build).toContain('npm run test:package-manager'); expect(publish).toContain('--provenance'); expect(publish).toContain('RELEASE_NPM_VERSION: 11.5.2'); expect(publish).toContain('npm install --global "npm@${RELEASE_NPM_VERSION}"'); @@ -161,12 +263,56 @@ describe('zero-touch release workflow contracts', () => { expect(publish).toContain('Verify complete PyPI file set'); expect(publish).toContain('remote == local'); expect(publish).not.toContain('try:\n try:'); - expect(publish).toContain('wingetcreate submit'); + expect(publish).toContain('wingetcreate.exe submit'); expect(publish).toContain('choco push'); expect(combined).toContain('/.github/workflows/release-train.yml@'); expect(combined).not.toContain('/.github/workflows/release-build.yml@'); }); + it('binds moderated submissions to exact provider evidence and fail-closed preflights', () => { + const publish = workflowSource('release-publish.yml'); + const train = workflowSource('release-train.yml'); + expect(publish).toContain("schemaVersion = 'guardscan.moderated-submission.v1'"); + expect(publish).toContain('release-winget-evidence-${{ inputs.tag }}'); + expect(publish).toContain('release-chocolatey-evidence-${{ inputs.tag }}'); + expect(publish).toContain('RELEASE_WINGETCREATE_VERSION: 1.12.13.0'); + expect(publish).toContain( + 'RELEASE_WINGETCREATE_SHA256: 24042bd37915805615e6cf969ac57c6439124c3fe85823327f5f3fb24bd9ffea' + ); + expect(publish).not.toContain('dotnet tool install --global wingetcreate'); + expect(publish).toContain('Pinned wingetcreate executable failed SHA-256 verification'); + expect(publish).toContain('repos/microsoft/winget-pkgs/contents/${manifestPath}'); + expect(publish).toContain('repos/microsoft/winget-pkgs/pulls/$($pullRequest.number)/files'); + expect(publish).toContain('https://community.chocolatey.org/api/v2/package/guardscan/$version'); + expect(publish).toContain("throw 'WinGet public manifest integrity conflict'"); + expect(publish).toContain("throw 'Chocolatey public package integrity conflict'"); + expect(publish).toContain('protected release ledger prevents a blind duplicate'); + expect(publish.match(/throw 'Unable to fetch protected release ledger'/g)).toHaveLength(2); + expect(train).toContain('release-winget-evidence-${{ needs.prepare.outputs.tag }}'); + expect(train).toContain('release-chocolatey-evidence-${{ needs.prepare.outputs.tag }}'); + expect(train).toContain('readModeratedEvidence'); + expect(train).toContain("evidence.schemaVersion !== 'guardscan.moderated-submission.v1'"); + expect(train).toContain('evidence.remoteIdentity'); + expect(train).toContain('evidence.remoteDigest'); + expect(train).toContain('expectedRemoteIdentity'); + expect(train).toContain('submission: evidence'); + expect(train).toContain('releaseTrainChannels(process.env.RELEASE_CHANNEL)'); + expect(train).not.toMatch( + /for \(const channel of \['winget', 'chocolatey'\]\)[\s\S]*?remoteIdentity: `\$\{channel\}:\$\{tag\}`/ + ); + }); + + it('requires exact public moderated CLI versions after discovery', () => { + const canary = workflowSource('release-canary.yml'); + const publish = workflowSource('release-publish.yml'); + expect(canary).toContain('$installedVersion = (& guardscan --version | Out-String).Trim()'); + expect(canary).toContain('$installedVersion -ne \'${{ matrix.train.version }}\''); + expect(publish.match(/\$installedVersion -ne '\$\{\{ inputs\.tag \}\}'\.TrimStart\('v'\)/g)) + .toHaveLength(2); + expect(publish).toContain('WinGet is unavailable on the selected Windows runner'); + expect(publish).toContain('Chocolatey is unavailable on the selected Windows runner'); + }); + it('uses one cryptographically bound shared Homebrew and Scoop catalog', () => { const publish = workflowSource('release-publish.yml'); const canary = workflowSource('release-canary.yml'); diff --git a/cli/__tests__/scripts/standalone-artifact.test.ts b/cli/__tests__/scripts/standalone-artifact.test.ts new file mode 100644 index 0000000..f9af78d --- /dev/null +++ b/cli/__tests__/scripts/standalone-artifact.test.ts @@ -0,0 +1,125 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const { + buildStandaloneArtifact, +} = require('../../scripts/release/standalone-artifact') as { + buildStandaloneArtifact: ( + source: Record, + executableFile: string, + platform: Record, + outputDir: string, + timestamp: string, + evidenceFile: string + ) => {metadata: Record}; +}; + +const source = { + version: '1.2.3', + tag: 'v1.2.3', + commit: 'a'.repeat(40), +}; +const platform = {os: 'linux', arch: 'x64', libc: 'glibc'}; +const timestamp = '2026-08-02T12:00:00.000Z'; + +function optionalCapabilities(): Record { + return { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: false, + mode: 'estimated', + sampleTokenCount: 7, + safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }, + }; +} + +function evidence(): Record { + return { + schemaVersion: 'guardscan.standalone-evidence.v1', + ...source, + platform, + optionalCapabilities: optionalCapabilities(), + signatures: [{ + type: 'sigstore', + url: `https://github.com/ntanwir10/GuardScan/releases/download/${source.tag}/linux.sigstore.json`, + verified: true, + }], + sboms: [{ + type: 'spdx', + url: `https://github.com/ntanwir10/GuardScan/releases/download/${source.tag}/guardscan.spdx.json`, + verified: true, + }], + provenance: { + type: 'slsa', + url: 'https://github.com/ntanwir10/GuardScan/attestations/123', + verified: true, + }, + }; +} + +function withArtifact( + descriptor: Record, + assertion: (result: {metadata: Record}) => void +): void { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-standalone-artifact-')); + try { + const executable = path.join(root, 'guardscan'); + const evidenceFile = path.join(root, 'evidence.json'); + fs.writeFileSync(executable, 'signed executable'); + fs.writeFileSync(evidenceFile, `${JSON.stringify(descriptor)}\n`); + assertion(buildStandaloneArtifact( + source, + executable, + platform, + path.join(root, 'output'), + timestamp, + evidenceFile + )); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } +} + +describe('production standalone artifact evidence', () => { + it('binds observed optional capability evidence into artifact metadata', () => { + const descriptor = evidence(); + + withArtifact(descriptor, result => { + expect(result.metadata.artifact.optionalCapabilities) + .toEqual(descriptor.optionalCapabilities); + expect(result.metadata.artifact.capabilities).toEqual({ + coreScan: true, + sbom: true, + chartRendering: false, + accurateTokenCounting: false, + }); + }); + }); + + it('rejects standalone evidence without observed optional capability evidence', () => { + const descriptor = evidence(); + delete descriptor.optionalCapabilities; + + expect(() => withArtifact(descriptor, () => undefined)).toThrow( + /standalone reduced-capability contract failed/ + ); + }); + + it('rejects inconsistent optional capability evidence', () => { + const descriptor = evidence(); + descriptor.optionalCapabilities.tokenCounting.dependencyAvailable = true; + + expect(() => withArtifact(descriptor, () => undefined)).toThrow( + /standalone reduced-capability contract failed/ + ); + }); +}); diff --git a/cli/__tests__/scripts/standalone-builder.test.ts b/cli/__tests__/scripts/standalone-builder.test.ts index a45af5d..9eed22e 100644 --- a/cli/__tests__/scripts/standalone-builder.test.ts +++ b/cli/__tests__/scripts/standalone-builder.test.ts @@ -6,6 +6,7 @@ const { OPTIONAL_EXTERNALS, PROTOTYPE_SCHEMA, assertExternalAllowlist, + assertReducedCapabilityEvidence, bundleOptions, externalPackages, hostPlatform, @@ -14,6 +15,7 @@ const { OPTIONAL_EXTERNALS: string[]; PROTOTYPE_SCHEMA: string; assertExternalAllowlist: (metafile: Record) => string[]; + assertReducedCapabilityEvidence: (evidence: Record) => Record; bundleOptions: (entryPoint: string, outputFile: string) => Record; externalPackages: (metafile: Record) => string[]; hostPlatform: () => {os: string; arch: string}; @@ -105,6 +107,56 @@ describe('standalone executable builder contract', () => { expect(PROTOTYPE_SCHEMA).toBe('guardscan.standalone-prototype.v1'); }); + it('accepts observed reduced-capability evidence from the standalone executable', () => { + const evidence = { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: false, + mode: 'estimated', + sampleTokenCount: 7, + safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }, + }; + + expect(assertReducedCapabilityEvidence(evidence)).toBe(evidence); + }); + + it.each([ + ['tiktoken was unexpectedly available', { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', dependencyAvailable: true, mode: 'accurate', + sampleTokenCount: 7, safeFallbackObserved: false, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', dependencyAvailable: false, + mode: 'unavailable', safeFallbackObserved: true, + }, + }], + ['chart fallback was not observed', { + schemaVersion: 'guardscan.runtime-capabilities.v1', + tokenCounting: { + dependency: 'tiktoken', dependencyAvailable: false, mode: 'estimated', + sampleTokenCount: 7, safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', dependencyAvailable: false, + mode: 'unavailable', safeFallbackObserved: false, + }, + }], + ])('rejects standalone evidence when %s', (_label, evidence) => { + expect(() => assertReducedCapabilityEvidence(evidence)).toThrow( + /standalone reduced-capability contract failed/ + ); + }); + it('ignores Node built-ins and rejects undeclared runtime package dependencies', () => { const allowed = metafile([ {path: 'node:fs', external: true}, diff --git a/cli/__tests__/utils/runtime-capabilities.test.ts b/cli/__tests__/utils/runtime-capabilities.test.ts new file mode 100644 index 0000000..21c9139 --- /dev/null +++ b/cli/__tests__/utils/runtime-capabilities.test.ts @@ -0,0 +1,77 @@ +import { + collectRuntimeCapabilities, + RUNTIME_CAPABILITY_SCHEMA, +} from '../../src/utils/runtime-capabilities'; + +function tokenCounter(available: boolean, mode: 'accurate' | 'estimated') { + return { + countTokens: jest.fn().mockReturnValue({count: 7, method: mode, model: 'gpt-4o'}), + getStatus: jest.fn().mockReturnValue({ + tiktokenAvailable: available, + claudeTokenizerAvailable: false, + recommendedDependencies: [], + }), + cleanup: jest.fn(), + }; +} + +describe('runtime capability evidence', () => { + it('records both safe reduced-capability paths when optional modules are unavailable', async () => { + const counter = tokenCounter(false, 'estimated'); + const missingChart = Object.assign( + new Error("Cannot find module 'chartjs-node-canvas'"), + {code: 'MODULE_NOT_FOUND'} + ); + + const evidence = await collectRuntimeCapabilities({ + createTokenCounter: () => counter, + loadChartRenderer: async () => { throw missingChart; }, + }); + + expect(evidence).toEqual({ + schemaVersion: RUNTIME_CAPABILITY_SCHEMA, + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: false, + mode: 'estimated', + sampleTokenCount: 7, + safeFallbackObserved: true, + }, + chartRendering: { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }, + }); + expect(counter.cleanup).toHaveBeenCalledTimes(1); + }); + + it('preserves native optional capabilities when the modules are installed', async () => { + const counter = tokenCounter(true, 'accurate'); + + const evidence = await collectRuntimeCapabilities({ + createTokenCounter: () => counter, + loadChartRenderer: async () => ({chartGenerator: {}}), + }); + + expect(evidence.tokenCounting).toMatchObject({ + dependencyAvailable: true, + mode: 'accurate', + safeFallbackObserved: false, + }); + expect(evidence.chartRendering).toEqual({ + dependency: 'chartjs-node-canvas', + dependencyAvailable: true, + mode: 'native', + safeFallbackObserved: false, + }); + }); + + it('fails closed when chart loading fails for an unexpected reason', async () => { + await expect(collectRuntimeCapabilities({ + createTokenCounter: () => tokenCounter(false, 'estimated'), + loadChartRenderer: async () => { throw new Error('chart initialization corrupted'); }, + })).rejects.toThrow('chart initialization corrupted'); + }); +}); diff --git a/cli/schemas/guardscan.release-event.v1.schema.json b/cli/schemas/guardscan.release-event.v1.schema.json index 6962896..0b21c14 100644 --- a/cli/schemas/guardscan.release-event.v1.schema.json +++ b/cli/schemas/guardscan.release-event.v1.schema.json @@ -44,6 +44,7 @@ "canary_recorded", "promotion_decided", "rollback_started", + "action_required", "withdrawn", "superseded", "incident_opened", diff --git a/cli/schemas/guardscan.release-manifest.v1.schema.json b/cli/schemas/guardscan.release-manifest.v1.schema.json index 18aa722..0b880c4 100644 --- a/cli/schemas/guardscan.release-manifest.v1.schema.json +++ b/cli/schemas/guardscan.release-manifest.v1.schema.json @@ -101,6 +101,48 @@ "accurateTokenCounting": { "type": "boolean" } } }, + "optionalCapabilities": { + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "tokenCounting", "chartRendering"], + "properties": { + "schemaVersion": { "const": "guardscan.runtime-capabilities.v1" }, + "tokenCounting": { + "type": "object", + "additionalProperties": false, + "required": [ + "dependency", + "dependencyAvailable", + "mode", + "sampleTokenCount", + "safeFallbackObserved" + ], + "properties": { + "dependency": { "const": "tiktoken" }, + "dependencyAvailable": { "const": false }, + "mode": { "const": "estimated" }, + "sampleTokenCount": { "type": "integer", "minimum": 1 }, + "safeFallbackObserved": { "const": true } + } + }, + "chartRendering": { + "type": "object", + "additionalProperties": false, + "required": [ + "dependency", + "dependencyAvailable", + "mode", + "safeFallbackObserved" + ], + "properties": { + "dependency": { "const": "chartjs-node-canvas" }, + "dependencyAvailable": { "const": false }, + "mode": { "const": "unavailable" }, + "safeFallbackObserved": { "const": true } + } + } + } + }, "source": { "type": "object", "additionalProperties": false, @@ -204,6 +246,7 @@ "archiveFormat": {}, "entrypoint": {}, "url": {}, + "optionalCapabilities": {}, "signatures": {}, "sboms": {}, "archiveEntries": {}, @@ -215,6 +258,7 @@ "archiveFormat", "entrypoint", "url", + "optionalCapabilities", "signatures", "sboms", "archiveEntries", @@ -264,6 +308,7 @@ "sha256": { "$ref": "#/definitions/sha256" }, "source": { "$ref": "#/definitions/source" }, "capabilities": { "$ref": "#/definitions/capabilities" }, + "optionalCapabilities": { "$ref": "#/definitions/optionalCapabilities" }, "platform": { "$ref": "#/definitions/platform" }, "archiveFormat": { "enum": ["tar.gz", "zip"] }, "entrypoint": { diff --git a/cli/schemas/guardscan.release-state.v2.schema.json b/cli/schemas/guardscan.release-state.v2.schema.json index dab10ae..a85bd86 100644 --- a/cli/schemas/guardscan.release-state.v2.schema.json +++ b/cli/schemas/guardscan.release-state.v2.schema.json @@ -41,6 +41,10 @@ "type": "object", "additionalProperties": { "$ref": "#/definitions/incident" } }, + "actionRequired": { + "type": "array", + "items": { "$ref": "#/definitions/actionRequired" } + }, "promotion": { "type": "object" } }, "definitions": { @@ -118,6 +122,18 @@ "resolvedAt": { "type": "string", "format": "date-time" }, "summary": { "type": "string", "minLength": 1, "maxLength": 2000 } } + }, + "actionRequired": { + "type": "object", + "additionalProperties": false, + "required": ["channel", "action", "authority", "reason", "requestedAt"], + "properties": { + "channel": { "type": "string", "minLength": 1, "maxLength": 50 }, + "action": { "type": "string", "minLength": 1, "maxLength": 200 }, + "authority": { "type": "string", "minLength": 1, "maxLength": 100 }, + "reason": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "requestedAt": { "type": "string", "format": "date-time" } + } } } } diff --git a/cli/scripts/release/events.js b/cli/scripts/release/events.js index 8f7c047..bd40ad5 100644 --- a/cli/scripts/release/events.js +++ b/cli/scripts/release/events.js @@ -21,6 +21,7 @@ const EVENT_TYPES = Object.freeze([ 'canary_recorded', 'promotion_decided', 'rollback_started', + 'action_required', 'withdrawn', 'superseded', 'incident_opened', @@ -126,6 +127,21 @@ function validateCatalogEvidence(event) { } } +function validateActionRequired(event) { + if (event.type !== 'action_required') return; + if (!event.channel) throw new Error('action_required requires a release channel'); + const payload = event.payload; + const keys = Object.keys(payload).sort(); + const expectedKeys = ['action', 'authority', 'reason']; + if (keys.join('\n') !== expectedKeys.join('\n') + || expectedKeys.some(key => typeof payload[key] !== 'string' || payload[key].length < 1)) { + throw new Error('action_required payload must contain only action, authority, and reason'); + } + if (payload.action.length > 200 || payload.authority.length > 100 || payload.reason.length > 2000) { + throw new Error('action_required payload exceeds its bounded field length'); + } +} + function validateEvent(event, previous) { if (!event || typeof event !== 'object' || Array.isArray(event)) { throw new Error('release event must be an object'); @@ -152,6 +168,7 @@ function validateEvent(event, previous) { throw new Error('release event payload must be an object'); } validateCatalogEvidence(event); + validateActionRequired(event); assertCanonicalTimestamp(event.timestamp, 'release event timestamp'); if (!/^[a-f0-9]{64}$/.test(event.eventHash || '') || event.eventHash !== eventDigest(event)) { @@ -305,6 +322,7 @@ function materializeReleaseState(events) { channels: initialChannels(first), canaries: {}, incidents: {}, + actionRequired: [], promotion: undefined, }; for (const event of events) { @@ -363,6 +381,15 @@ function materializeReleaseState(events) { }; } if (event.type === 'promotion_decided') state.promotion = event.payload; + if (event.type === 'action_required') { + state.actionRequired.push({ + channel: event.channel, + action: event.payload.action, + authority: event.payload.authority, + reason: event.payload.reason, + requestedAt: event.timestamp, + }); + } } if (!state.manifestSha256) delete state.manifestSha256; if (!state.promotion) delete state.promotion; diff --git a/cli/scripts/release/index.js b/cli/scripts/release/index.js index 436ccc7..09c1a6d 100644 --- a/cli/scripts/release/index.js +++ b/cli/scripts/release/index.js @@ -136,6 +136,8 @@ function printHelp() { ' --manifest-sha256 SHA Exact release-manifest.json SHA-256', ' --generator-repository R Repository containing the catalog renderer', ' --generator-commit SHA Exact renderer source commit', + ' --known-good VERSION Verified stable rollback source version', + ' --known-good-commit SHA Exact verified rollback source commit', '', ].join('\n')); } @@ -494,14 +496,53 @@ async function main(argv) { } if (command === 'rollback') { - requireOptions('rollback', options, ['ledger', 'timestamp', 'idempotencyKey']); + requireOptions('rollback', options, [ + 'ledger', + 'timestamp', + 'idempotencyKey', + 'knownGood', + 'knownGoodCommit', + ]); const materialized = materializeReleaseState(readEvents(options.ledger)); - const plan = planRollback(materialized, options.knownGood); - const result = appendEvent(options.ledger, eventIdentity(source, options, 'rollback_started', { - knownGoodVersion: options.knownGood || null, + const plan = planRollback(materialized, options.knownGood, options.knownGoodCommit); + const results = []; + results.push(appendEvent(options.ledger, eventIdentity(source, options, 'rollback_started', { + knownGoodVersion: options.knownGood, + knownGoodCommit: options.knownGoodCommit, forwardFixVersion: plan.forwardFixVersion, - })); - process.stdout.write(`${JSON.stringify({changed: result.changed, plan}, null, 2)}\n`); + forwardFixBranch: plan.forwardFixBranch, + }))); + const authorityReasons = { + npm: 'GitHub OIDC trusted publishing cannot deprecate an existing npm version', + pypi: 'PyPI trusted publishing cannot yank an existing release', + 'homebrew-core': 'Homebrew Core changes require an upstream maintainer-reviewed pull request', + winget: 'WinGet correction requires upstream submission and moderation authority', + chocolatey: 'Chocolatey unlisting or superseding requires publisher moderation authority', + }; + for (const action of plan.actions.filter(item => item.automation === 'external-action-required')) { + const eventOptions = { + ...options, + idempotencyKey: `${options.idempotencyKey}:action-required:${action.channel}`, + }; + results.push(appendEvent(options.ledger, eventIdentity( + source, + eventOptions, + 'action_required', + { + action: action.action, + authority: action.authority, + reason: authorityReasons[action.channel], + }, + action.channel + ))); + } + process.stdout.write(`${JSON.stringify({ + ...plan, + ledger: { + changed: results.some(result => result.changed), + eventHashes: results.map(result => result.event.eventHash), + }, + }, null, 2)}\n`); return; } diff --git a/cli/scripts/release/lib.js b/cli/scripts/release/lib.js index 270a60a..5221197 100644 --- a/cli/scripts/release/lib.js +++ b/cli/scripts/release/lib.js @@ -31,6 +31,22 @@ const CHANNELS = Object.freeze([ {id: 'pypi', phase: 'full', operation: 'publish', artifacts: ['python-wheel', 'standalone']}, ]); +const REQUIRED_RC_CHANNELS = Object.freeze([ + 'npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi', +]); + +function releaseTrainChannels(channel, options = {}) { + if (!['rc', 'stable'].includes(channel)) { + throw new Error(`unsupported release train channel: ${channel}`); + } + const channels = [...REQUIRED_RC_CHANNELS]; + if (channel === 'stable') { + if (options.homebrewCoreEnabled === true) channels.push('homebrew-core'); + channels.push('winget', 'chocolatey'); + } + return channels; +} + function readBounded(file, label = 'file') { const descriptor = fs.openSync(file, 'r'); try { @@ -113,8 +129,28 @@ function validateSource(options = {}) { if (semver.valid(packageJson.version)) { const changelog = readBounded(path.join(packageRoot, 'CHANGELOG.md'), 'CHANGELOG.md'); const escapedVersion = packageJson.version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - if (!new RegExp(`^## \\[${escapedVersion}\\](?:\\s|$)`, 'm').test(changelog)) { + const releaseHeadings = changelog.match( + new RegExp(`^## \\[${escapedVersion}\\](?:\\s|$)`, 'gm') + ) || []; + if (releaseHeadings.length === 0) { errors.push(`CHANGELOG.md has no release section for ${packageJson.version}`); + } else if (releaseHeadings.length !== 1) { + errors.push(`CHANGELOG.md must contain exactly one release section for ${packageJson.version}`); + } + if (semver.prerelease(packageJson.version) === null) { + const unreleasedHeading = /^## \[Unreleased\][^\n]*$/m.exec(changelog); + const unreleasedBody = unreleasedHeading + ? changelog.slice( + unreleasedHeading.index + unreleasedHeading[0].length, + (() => { + const next = changelog.indexOf('\n## [', unreleasedHeading.index + unreleasedHeading[0].length); + return next === -1 ? changelog.length : next; + })() + ).trim() + : ''; + if (unreleasedBody.length > 0) { + errors.push(`CHANGELOG.md Unreleased section must be empty for stable release ${packageJson.version}`); + } } } @@ -351,6 +387,7 @@ module.exports = { prepareRelease, readBounded, readJson, + releaseTrainChannels, resolveGitCommit, resolveGitTimestamp, summarizeState, diff --git a/cli/scripts/release/reconcile.js b/cli/scripts/release/reconcile.js index cbd3bbf..5b96810 100644 --- a/cli/scripts/release/reconcile.js +++ b/cli/scripts/release/reconcile.js @@ -58,10 +58,25 @@ function nextPatchVersion(version) { return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; } -function planRollback(state, knownGoodVersion) { - if (knownGoodVersion && !semver.valid(knownGoodVersion)) { +function planRollback(state, knownGoodVersion, knownGoodCommit) { + if (!knownGoodVersion) throw new Error('a verified known-good version is required'); + if (!semver.valid(knownGoodVersion) || semver.prerelease(knownGoodVersion) !== null) { throw new Error('known-good version is invalid'); } + if (!knownGoodCommit) throw new Error('known-good commit is required'); + if (!/^[a-f0-9]{40}$/.test(knownGoodCommit)) throw new Error('known-good commit is invalid'); + if (!semver.lt(knownGoodVersion, state.version)) { + throw new Error('known-good version must precede the defective release'); + } + const forwardFixVersion = nextPatchVersion(state.version); + const forwardFixBranch = `release/forward-fix-v${forwardFixVersion}-from-v${knownGoodVersion}`; + const externalAuthority = { + npm: 'npm-maintainer', + pypi: 'pypi-maintainer', + 'homebrew-core': 'homebrew-core-maintainer', + winget: 'winget-maintainer', + chocolatey: 'chocolatey-maintainer', + }; const actions = []; for (const [channel, channelState] of Object.entries(state.channels || {})) { if (!['published', 'submitted', 'accepted', 'verified'].includes(channelState.status)) continue; @@ -75,19 +90,39 @@ function planRollback(state, knownGoodVersion) { yarn: 'verify-npm-forward-fix', bun: 'verify-npm-forward-fix', pypi: 'yank-and-forward-fix', - homebrew: knownGoodVersion ? 'redirect-to-known-good' : 'remove-new-listing', + homebrew: 'redirect-to-known-good', 'homebrew-core': 'submit-corrective-formula-or-revision', - scoop: knownGoodVersion ? 'redirect-to-known-good' : 'remove-new-listing', + scoop: 'redirect-to-known-good', winget: 'submit-corrective-manifest', chocolatey: 'unlist-or-supersede', }[channel], + automation: externalAuthority[channel] + ? 'external-action-required' + : 'repository-automated', + ...(externalAuthority[channel] ? {authority: externalAuthority[channel]} : {}), }; actions.push(action); } return { + schemaVersion: 'guardscan.rollback-plan.v1', version: state.version, - knownGoodVersion: knownGoodVersion || null, - forwardFixVersion: nextPatchVersion(state.version), + tag: state.tag, + commit: state.commit, + status: 'planned', + knownGood: { + version: knownGoodVersion, + tag: `v${knownGoodVersion}`, + commit: knownGoodCommit, + }, + knownGoodVersion, + knownGoodCommit, + forwardFixVersion, + forwardFixBranch, + repositoryActions: [ + {id: 'deactivate-train', action: 'remove-from-active-versions', status: 'planned'}, + {id: 'forward-fix-pr', action: 'open-or-update-pull-request', status: 'planned'}, + {id: 'shared-catalog-rollback', action: 'open-or-update-catalog-pull-request', status: 'planned'}, + ], actions: actions.sort((a, b) => a.channel.localeCompare(b.channel)), }; } diff --git a/cli/scripts/release/recovery-source.js b/cli/scripts/release/recovery-source.js new file mode 100644 index 0000000..c79746e --- /dev/null +++ b/cli/scripts/release/recovery-source.js @@ -0,0 +1,109 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const semver = require('semver'); + +const FILES = Object.freeze([ + 'cli/CHANGELOG.md', + 'cli/package-lock.json', + 'cli/package.json', +]); + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function readRegularFile(repositoryRoot, relative) { + const file = path.join(repositoryRoot, ...relative.split('/')); + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`forward-fix source is not a regular file: ${relative}`); + } + if (stat.size > 10 * 1024 * 1024) { + throw new Error(`forward-fix source exceeds its size limit: ${relative}`); + } + return {file, contents: fs.readFileSync(file, 'utf8')}; +} + +function recoverySection(input) { + return [ + `## [${input.forwardFixVersion}]`, + '', + '### Fixed', + '', + `- Restore verified v${input.knownGoodVersion} source as a forward fix for defective v${input.defectiveVersion} without replacing immutable release artifacts.`, + ].join('\n'); +} + +function updateChangelog(contents, input) { + const section = recoverySection(input); + if (contents.includes(`${section}\n`) || contents.endsWith(section)) return contents; + const headingPattern = new RegExp(`^## \\[${input.forwardFixVersion.replace(/\./g, '\\.')}\\](?:\\s|$)`, 'm'); + if (headingPattern.test(contents)) { + throw new Error(`CHANGELOG already has conflicting notes for ${input.forwardFixVersion}`); + } + const unreleased = /^## \[Unreleased\][^\n]*$/m.exec(contents); + if (!unreleased) throw new Error('CHANGELOG has no Unreleased section'); + const headingEnd = unreleased.index + unreleased[0].length; + const nextHeading = contents.indexOf('\n## [', headingEnd); + const boundary = nextHeading === -1 ? contents.length : nextHeading; + if (contents.slice(headingEnd, boundary).trim().length > 0) { + throw new Error('known-good CHANGELOG Unreleased section must be empty'); + } + return `${contents.slice(0, headingEnd)}\n\n${section}${contents.slice(boundary)}`; +} + +function prepareForwardFixSource(repositoryRoot, input) { + const root = path.resolve(repositoryRoot); + if (path.dirname(root) === root) throw new Error('refusing to prepare a filesystem root'); + const {knownGoodVersion, defectiveVersion, forwardFixVersion} = input; + if (!semver.valid(knownGoodVersion) || semver.prerelease(knownGoodVersion) !== null) { + throw new Error('forward-fix known-good version is invalid'); + } + if (!semver.valid(defectiveVersion) || semver.prerelease(defectiveVersion) !== null) { + throw new Error('forward-fix defective version is invalid'); + } + if (forwardFixVersion !== semver.inc(defectiveVersion, 'patch')) { + throw new Error('forward-fix version must be the next patch after the defective release'); + } + const packageSource = readRegularFile(root, 'cli/package.json'); + const lockSource = readRegularFile(root, 'cli/package-lock.json'); + const changelogSource = readRegularFile(root, 'cli/CHANGELOG.md'); + const packageJson = JSON.parse(packageSource.contents); + const packageLock = JSON.parse(lockSource.contents); + for (const [label, version] of [ + ['package.json', packageJson.version], + ['package-lock.json', packageLock.version], + ['package-lock.json root package', packageLock.packages?.['']?.version], + ]) { + if (![knownGoodVersion, forwardFixVersion].includes(version)) { + throw new Error(`${label} does not match the known-good or forward-fix version`); + } + } + packageJson.version = forwardFixVersion; + packageLock.version = forwardFixVersion; + packageLock.packages[''].version = forwardFixVersion; + const outputs = { + 'cli/package.json': `${JSON.stringify(packageJson, null, 2)}\n`, + 'cli/package-lock.json': `${JSON.stringify(packageLock, null, 2)}\n`, + 'cli/CHANGELOG.md': updateChangelog(changelogSource.contents, input), + }; + let changed = false; + for (const relative of FILES) { + const target = path.join(root, ...relative.split('/')); + if (fs.readFileSync(target, 'utf8') === outputs[relative]) continue; + fs.writeFileSync(target, outputs[relative], {encoding: 'utf8', mode: 0o600}); + changed = true; + } + return { + schemaVersion: 'guardscan.forward-fix-source.v1', + changed, + version: forwardFixVersion, + files: [...FILES], + digests: Object.fromEntries(FILES.map(relative => [relative, sha256(outputs[relative])])), + }; +} + +module.exports = {prepareForwardFixSource}; diff --git a/cli/scripts/release/standalone-artifact.js b/cli/scripts/release/standalone-artifact.js index 37be04f..7ed50af 100644 --- a/cli/scripts/release/standalone-artifact.js +++ b/cli/scripts/release/standalone-artifact.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const {inspectArchive, writeArchive} = require('./archive'); const {readJson} = require('./lib'); +const {assertReducedCapabilityEvidence} = require('./standalone'); const ARTIFACT_METADATA_SCHEMA = 'guardscan.artifact-metadata.v1'; const EVIDENCE_SCHEMA = 'guardscan.standalone-evidence.v1'; @@ -26,6 +27,7 @@ function assertEvidence(source, platform, evidence) { || !evidence.provenance) { throw new Error('standalone evidence is incomplete'); } + assertReducedCapabilityEvidence(evidence.optionalCapabilities); return evidence; } @@ -73,9 +75,10 @@ function buildStandaloneArtifact(source, executableFile, platform, outputDir, ti capabilities: { coreScan: true, sbom: true, - chartRendering: false, - accurateTokenCounting: false, + chartRendering: evidence.optionalCapabilities.chartRendering.dependencyAvailable, + accurateTokenCounting: evidence.optionalCapabilities.tokenCounting.mode === 'accurate', }, + optionalCapabilities: evidence.optionalCapabilities, platform, archiveFormat: format, entrypoint: executableName, diff --git a/cli/scripts/release/standalone.js b/cli/scripts/release/standalone.js index e0a6682..de25b70 100644 --- a/cli/scripts/release/standalone.js +++ b/cli/scripts/release/standalone.js @@ -11,6 +11,7 @@ const {inject} = require('postject'); const {assertRuntimeArtifactClean} = require('./runtime-artifact-policy'); const PROTOTYPE_SCHEMA = 'guardscan.standalone-prototype.v1'; +const RUNTIME_CAPABILITY_SCHEMA = 'guardscan.runtime-capabilities.v1'; const SEA_FUSE = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; const OPTIONAL_EXTERNALS = Object.freeze(['chartjs-node-canvas', 'tiktoken']); const MAX_BUNDLE_BYTES = 256 * 1024 * 1024; @@ -164,6 +165,26 @@ function assertSuccessfulOutput(result, expected, label) { } } +function assertReducedCapabilityEvidence(evidence) { + const tokenCounting = evidence?.tokenCounting; + const chartRendering = evidence?.chartRendering; + const valid = evidence?.schemaVersion === RUNTIME_CAPABILITY_SCHEMA + && tokenCounting?.dependency === 'tiktoken' + && tokenCounting.dependencyAvailable === false + && tokenCounting.mode === 'estimated' + && Number.isInteger(tokenCounting.sampleTokenCount) + && tokenCounting.sampleTokenCount > 0 + && tokenCounting.safeFallbackObserved === true + && chartRendering?.dependency === 'chartjs-node-canvas' + && chartRendering.dependencyAvailable === false + && chartRendering.mode === 'unavailable' + && chartRendering.safeFallbackObserved === true; + if (!valid) { + throw new Error('standalone reduced-capability contract failed'); + } + return evidence; +} + function smokeStandalone(executable, version) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-standalone-smoke-')); const project = path.join(root, 'project'); @@ -216,11 +237,31 @@ function smokeStandalone(executable, version) { if (!telemetry.stdout.includes('Consent: disabled')) { throw new Error('standalone telemetry opt-out contract failed'); } + const capabilityResult = run( + executable, + ['--no-telemetry', 'capabilities', '--json'], + project, + env + ); + let optionalCapabilities; + try { + optionalCapabilities = assertReducedCapabilityEvidence( + JSON.parse(capabilityResult.stdout.trim()) + ); + } catch (error) { + throw new Error( + `standalone capability evidence was invalid: ${error instanceof Error ? error.message : error}` + ); + } + const optionalCapabilitiesUnavailableSafely = + optionalCapabilities.tokenCounting.safeFallbackObserved === true + && optionalCapabilities.chartRendering.safeFallbackObserved === true; return { valid: true, nodeAbsentFromPath: true, packageManagersAbsentFromPath: true, - optionalCapabilitiesUnavailableSafely: true, + optionalCapabilitiesUnavailableSafely, + optionalCapabilities, spdx: true, cyclonedx: true, }; @@ -296,8 +337,8 @@ async function buildHostPrototype(source, outputDir) { capabilities: { coreScan: true, sbom: true, - chartRendering: false, - accurateTokenCounting: false, + chartRendering: smoke.optionalCapabilities.chartRendering.dependencyAvailable, + accurateTokenCounting: smoke.optionalCapabilities.tokenCounting.mode === 'accurate', }, optionalExternalPackages: externals, bundle, @@ -329,6 +370,7 @@ module.exports = { PROTOTYPE_SCHEMA, assertEmbeddableNodeRuntime, assertExternalAllowlist, + assertReducedCapabilityEvidence, buildHostPrototype, bundleOptions, externalPackages, diff --git a/cli/src/commands/capabilities.ts b/cli/src/commands/capabilities.ts new file mode 100644 index 0000000..c74db82 --- /dev/null +++ b/cli/src/commands/capabilities.ts @@ -0,0 +1,12 @@ +import { Command } from 'commander'; +import { collectRuntimeCapabilities } from '../utils/runtime-capabilities'; + +export function createCapabilitiesCommand(): Command { + return new Command('capabilities') + .description('Inspect optional runtime capabilities and safe fallback modes') + .option('--json', 'Emit compact machine-readable JSON') + .action(async (options: {json?: boolean}) => { + const evidence = await collectRuntimeCapabilities(); + process.stdout.write(`${JSON.stringify(evidence, null, options.json ? undefined : 2)}\n`); + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 311cbd5..4dc8999 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -51,6 +51,7 @@ async function main(): Promise { { createMetricsCommand }, { createCacheCommand }, { createTelemetryCommand }, + { createCapabilitiesCommand }, { createVulnerabilityCommand }, { checkForUpdates }, { displayLogo }, @@ -82,6 +83,7 @@ async function main(): Promise { import("./commands/metrics"), import("./commands/cache"), import("./commands/telemetry"), + import("./commands/capabilities"), import("./commands/vuln"), import("./utils/version"), import("./utils/ascii-art"), @@ -415,6 +417,7 @@ async function main(): Promise { program.addCommand(createMetricsCommand()); program.addCommand(createCacheCommand()); program.addCommand(createTelemetryCommand()); + program.addCommand(createCapabilitiesCommand()); program.addCommand(createVulnerabilityCommand()); if ( diff --git a/cli/src/utils/runtime-capabilities.ts b/cli/src/utils/runtime-capabilities.ts new file mode 100644 index 0000000..16f8433 --- /dev/null +++ b/cli/src/utils/runtime-capabilities.ts @@ -0,0 +1,95 @@ +import { AccurateTokenCounter, TokenCountResult } from '../providers/token-counter'; + +export const RUNTIME_CAPABILITY_SCHEMA = 'guardscan.runtime-capabilities.v1'; +const TOKEN_PROBE_TEXT = 'GuardScan standalone optional capability probe'; +const TOKEN_PROBE_MODEL = 'gpt-4o'; + +interface TokenCounterProbe { + countTokens(text: string, model: string): TokenCountResult; + getStatus(): {tiktokenAvailable: boolean}; + cleanup(): void; +} + +export interface RuntimeCapabilityProbeOptions { + createTokenCounter?: () => TokenCounterProbe; + loadChartRenderer?: () => Promise; +} + +export interface RuntimeCapabilityEvidence { + schemaVersion: typeof RUNTIME_CAPABILITY_SCHEMA; + tokenCounting: { + dependency: 'tiktoken'; + dependencyAvailable: boolean; + mode: TokenCountResult['method']; + sampleTokenCount: number; + safeFallbackObserved: boolean; + }; + chartRendering: { + dependency: 'chartjs-node-canvas'; + dependencyAvailable: boolean; + mode: 'native' | 'unavailable'; + safeFallbackObserved: boolean; + }; +} + +function isMissingChartDependency(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') + && error.message.includes('chartjs-node-canvas'); +} + +/** + * Exercise optional runtime boundaries and report what this exact executable can do. + * Unexpected initialization failures are not relabeled as a supported reduced mode. + */ +export async function collectRuntimeCapabilities( + options: RuntimeCapabilityProbeOptions = {} +): Promise { + const counter = options.createTokenCounter?.() ?? new AccurateTokenCounter(); + let tokenResult: TokenCountResult; + let tiktokenAvailable: boolean; + try { + tokenResult = counter.countTokens(TOKEN_PROBE_TEXT, TOKEN_PROBE_MODEL); + tiktokenAvailable = counter.getStatus().tiktokenAvailable; + } finally { + counter.cleanup(); + } + + let chartRendering: RuntimeCapabilityEvidence['chartRendering']; + try { + const loadChartRenderer = options.loadChartRenderer + ?? (() => import('./chart-generator')); + await loadChartRenderer(); + chartRendering = { + dependency: 'chartjs-node-canvas', + dependencyAvailable: true, + mode: 'native', + safeFallbackObserved: false, + }; + } catch (error) { + if (!isMissingChartDependency(error)) { + throw error; + } + chartRendering = { + dependency: 'chartjs-node-canvas', + dependencyAvailable: false, + mode: 'unavailable', + safeFallbackObserved: true, + }; + } + + return { + schemaVersion: RUNTIME_CAPABILITY_SCHEMA, + tokenCounting: { + dependency: 'tiktoken', + dependencyAvailable: tiktokenAvailable, + mode: tokenResult.method, + sampleTokenCount: tokenResult.count, + safeFallbackObserved: tokenResult.method === 'estimated', + }, + chartRendering, + }; +} diff --git a/docs/FUNCTIONAL_ACCEPTANCE.md b/docs/FUNCTIONAL_ACCEPTANCE.md new file mode 100644 index 0000000..8f6aa0d --- /dev/null +++ b/docs/FUNCTIONAL_ACCEPTANCE.md @@ -0,0 +1,119 @@ +# GuardScan functional acceptance + +This document is the launch evidence map for GuardScan `1.1.0`. It separates +implemented behavior from behavior proven in the exact artifact that users will +install. A command appearing in help, compiling successfully, or passing a mock +does not by itself make that command production-verified. + +The release ledger is authoritative for a version. Evidence is valid only when +it names the source commit, tag, artifact digest, platform, runtime, test, and +time. Every current-source change invalidates older exact-artifact evidence until +the required jobs rerun against the new commit. + +The current launch posture is **closed**: `RELEASE_AUTOMATION_ENABLED` remains +`false` until onboarding is complete. No `1.1.0-rc.1` soak or `1.1.0` public +verification is implied by this matrix. + +## Evidence classes + +Each command has one primary acceptance class. The class describes the strongest +required proof shape, while the evidence and gate columns state what is and is +not proved today. + +- **Offline exact-artifact proven**: a test invokes the packed npm tarball or SEA + executable in an isolated home with network/telemetry disabled. For SEA, Node + and package managers are absent from `PATH`. +- **Component/integration proven**: real GuardScan code is exercised from source + or compiled output, but the behavior is not yet invoked through every public + distribution artifact. +- **External-tool dependent**: GuardScan orchestration and safety boundaries can + be tested, but the result also depends on a project-owned tool such as a test + runner, linter, Lighthouse, k6, or mutation framework. +- **Mocked BYOK boundary**: prompt construction, routing, redaction, parsing, and + failure behavior are tested with deterministic fake providers. This does not + prove real credentials, quotas, endpoint compatibility, or model behavior. +- **Live provider rehearsal**: bounded, account-scoped evidence from a real + provider is required. Secrets and response content must not enter artifacts, + logs, manifests, telemetry, or the release ledger. + +## Command acceptance matrix + +| Command | Primary evidence class | Current strongest repository evidence | Install/runtime variants in scope | Current launch gate | +| --- | --- | --- | --- | --- | +| `guardscan init` | Offline exact-artifact proven | Packed npm smoke invokes `init` and verifies provider `none`, offline mode, telemetry disabled, and no client ID; compiled command and config-lifecycle tests cover updates. | npm global on Node 22/24; npm consumers inherit the same tarball. | Rerun the exact candidate tarball on Linux, macOS, and Windows and record its digest-bound result. | +| `guardscan run` | Component/integration proven | Command tests execute the static path, the opt-in AI path, exclusion handling, reports, and safe failure boundaries. | Node package and embedded-runtime channels; AI behavior varies by configured provider. | Add packed/SEA command invocation; separately complete live BYOK and local-provider rehearsals for `--with-ai`. | +| `guardscan scan` | Offline exact-artifact proven | Packed npm, npm/pnpm/Yarn/Bun consumer smoke, compiled end-to-end tests, and SEA smoke exercise offline static scanning and validate `guardscan.scan.v1`. | npm global; pnpm, Yarn Classic/Modern, Bun; all five SEA/wheel targets. | Produce current-tag artifacts, rerun every native/public canary, and bind reports to the release manifest. | +| `guardscan security` | Component/integration proven | Compiled end-to-end tests cover JSON, SARIF, finding-policy exits, partial inventory, and static-safe execution; Docker fixtures exercise installed source builds. | Node package and embedded-runtime channels. | Invoke the exact candidate tarball and each native artifact; retain malicious-input and policy evidence. | +| `guardscan test` | External-tool dependent | Test-runner and process-policy tests cover framework detection, argv-safe execution, timeouts, and failure reporting. | Requires compatible project test/lint commands in the host environment; standalone does not bundle project tools. | Rehearse supported project matrices on each OS and prove missing/hostile tools fail safely. | +| `guardscan sbom` | Offline exact-artifact proven | Packed npm and SEA smoke generate and parse SPDX 2.3 and CycloneDX 1.7; command tests cover deterministic local inventory. | npm/package-manager consumers and all five SEA/wheel targets. | Rerun exact candidate artifacts on all hosts and verify output schemas and manifest-bound artifact SBOMs. | +| `guardscan perf` | External-tool dependent | Load-test components exercise collection and reporting, but host tools and target services remain outside GuardScan's artifact. | Node or embedded runtime plus project-selected performance tools. | Add native command rehearsals with pinned k6/Lighthouse fixtures and explicit unavailable-tool behavior. | +| `guardscan mutation` | External-tool dependent | Mutation components test framework selection, argv-safe process execution, failure handling, and injection resistance. | Node or embedded runtime plus a supported mutation framework. | Run deterministic Stryker, mutmut, PIT, and custom-command fixtures where supported; document unsupported platform results. | +| `guardscan rules` | Component/integration proven | The compiled command/help contract exists; rule evaluation is exercised indirectly by scan-engine tests. Direct command execution is not yet an acceptance proof. | All runtime channels use the same compiled rule implementation. | Add direct list/enable/disable/custom-rule lifecycle tests, then invoke the exact npm and native artifacts. | +| `guardscan config` | Component/integration proven | Direct command and isolated config-lifecycle tests cover show/update, privacy defaults, provider changes, and secret-safe output. | All channels; state is isolated under `GUARDSCAN_HOME`. | Add exact-artifact lifecycle coverage on POSIX and Windows, including corrupt/unreadable configuration. | +| `guardscan status` | Component/integration proven | Compiled command surface and configuration/provider components are covered; direct installed-command state reporting is not yet proved. | All channels, with Node/SEA capability differences. | Add deterministic configured/unconfigured/degraded fixtures and exact npm/SEA invocation. | +| `guardscan reset` | Component/integration proven | Configuration reset behavior is covered through lifecycle components; installed CLI reset and cancellation are not yet separately proved. | All channels; mutates only the selected GuardScan home. | Add isolated confirmation/force tests on POSIX and Windows and prove unrelated files are retained. | +| `guardscan commit` | Mocked BYOK boundary | Provider factories, routing, cost guards, retries, and output parsers have deterministic tests; the command has no live-provider release evidence. | Node and embedded runtime with a user-selected BYOK or local provider. | Add direct command fixtures, then run bounded live rehearsals without committing or logging generated content. | +| `guardscan explain` | Mocked BYOK boundary | Code-explainer tests exercise prompt construction, provider output, file/range handling, and failure behavior with fakes. | Node and embedded runtime with BYOK/local provider access. | Invoke packaged artifacts and complete one bounded rehearsal per supported provider family. | +| `guardscan test-gen` | Mocked BYOK boundary | Compiled help and shared provider boundaries are covered; generated-test correctness is not currently command-level acceptance evidence. | Node and embedded runtime with BYOK/local provider access. | Add deterministic file/language/output fixtures, sandbox generated tests, and complete bounded provider rehearsals. | +| `guardscan docs` | Mocked BYOK boundary | Compiled help and shared provider boundaries are covered; direct documentation-generation behavior lacks a dedicated acceptance fixture. | Node and embedded runtime with BYOK/local provider access. | Add deterministic type/output/path tests, exact-artifact invocation, and bounded provider rehearsals. | +| `guardscan chat` | Live provider rehearsal | Deterministic RAG integration uses fake embeddings/provider responses and proves local retrieval boundaries; it does not prove a real provider session. | Node and embedded runtime; cloud BYOK and local Ollama/LM Studio are separate variants. | Rehearse one bounded conversation per supported provider family, including offline/local operation and signal-safe cancellation. | +| `guardscan refactor` | Mocked BYOK boundary | Refactoring feature tests cover parsing, suggestions, file selection, and malformed provider output using fakes. | Node and embedded runtime with BYOK/local provider access. | Invoke exact artifacts, prove no unapproved file writes, and complete bounded provider rehearsals. | +| `guardscan threat-model` | Mocked BYOK boundary | Compiled help and shared provider/security components are covered; no dedicated command acceptance fixture proves the final threat model. | Node and embedded runtime with BYOK/local provider access. | Add deterministic input/output/redaction fixtures and bounded provider rehearsals. | +| `guardscan migrate` | Mocked BYOK boundary | Compiled help and shared provider/process boundaries are covered; migration correctness is not directly proved. | Node and embedded runtime with BYOK/local provider access and project tooling. | Add dry-run, diff, rollback, and unsupported-language fixtures before any live-provider rehearsal. | +| `guardscan review` | Mocked BYOK boundary | Compiled option syntax and shared scan/provider components are tested; direct review-output acceptance remains incomplete. | Node and embedded runtime with BYOK/local provider access. | Add deterministic file/diff/report fixtures, exact-artifact invocation, and bounded provider rehearsals. | +| `guardscan models` | Component/integration proven | Model-registry, provider-factory, validation, redirect, and token-counter tests cover known model metadata without requiring a live completion. | All runtime channels; availability still depends on the configured provider. | Add exact-artifact list/validation tests and a non-secret live availability probe for each enabled provider family. | +| `guardscan routing` | Component/integration proven | Model-router and decorator tests cover deterministic selection, fallbacks, rate limits, retries, circuits, and observability. | Node and embedded runtime with one or more configured providers. | Add direct command fixtures and live failover rehearsal without exposing prompts or credentials. | +| `guardscan budget` | Component/integration proven | Direct command and cost-guard tests cover limits, usage display, reset, and provider/model accounting. | All channels; local state is scoped to the selected GuardScan home. | Add exact npm/SEA lifecycle tests, clock-boundary coverage, and concurrent-write evidence. | +| `guardscan metrics` | Component/integration proven | Metrics-collector tests cover collection and serialization; the installed command output is not yet an exact-artifact proof. | All channels; local-only unless a user explicitly exports data. | Add exact-artifact status/export fixtures and prove sensitive source/prompt content is excluded. | +| `guardscan cache` | Component/integration proven | Direct cache tests and compiled end-to-end execution cover clear/select/all behavior and prove the telemetry spool is retained. | All channels; paths differ on POSIX and Windows. | Invoke the exact candidate npm/SEA artifacts on all hosts and exercise corrupt, locked, and concurrent entries. | +| `guardscan telemetry` | Offline exact-artifact proven | Packed npm, package-manager consumer, compiled end-to-end, and SEA smoke prove disabled consent/status; unit and contract tests cover queue/delivery boundaries. | All channels; optional synchronization targets the configured service only after consent. | Reprove disabled status for exact artifacts and separately rehearse opt-in HTTPS delivery to a user-authorized endpoint. | +| `guardscan capabilities` | Offline exact-artifact proven | Unit/compiled JSON contracts and the SEA exact-executable smoke contract verify the explicit reduced profile. The current-source five-host SEA run is still required. | Node package reports full/available runtime features; SEA and wheel report `coreScan`/`sbom` true and chart/token extras false. | Pass the current SHA on all five SEA hosts and verify wheels forward byte-identical output. | +| `guardscan vuln` | Component/integration proven | Direct command, OSV client, dependency inventory, CISA KEV, and compiled end-to-end tests cover exact versions, offline snapshot behavior, fail-closed exit 2, and aliases `cve`/`audit`. | All channels; online OSV and offline signed-snapshot modes are distinct. | Invoke exact artifacts; verify signed snapshot refresh/expiry and run a bounded live OSV canary with recorded response metadata only. | + +## Major workflow acceptance + +| Workflow | Required evidence | Current repository evidence | Launch gate | +| --- | --- | --- | --- | +| Privacy-first initialization | Exact npm artifact on each supported Node/OS pair | Package smoke asserts provider `none`, offline mode, telemetry disabled, and no generated client ID. | Candidate tarball must pass the three-OS, Node 22/24 matrix. | +| Offline scan and policy reporting | Exact npm consumers and all five SEA targets | Package-manager, compiled CLI, and standalone smoke contracts validate static execution, schema, and policy. | Current-tag signed artifacts and public-install canaries must pass. | +| Project-code execution | Component plus hostile-tool integration | Process runner, execution policy, test runner, mutation, and injection tests use argv arrays and constrained policies. | Native fixtures must prove opt-in execution, timeouts, cancellation, and safe failure. | +| Vulnerability intelligence | Component, signed offline snapshot, and bounded live OSV | Inventory/OSV/KEV tests and offline fail-closed CLI behavior exist. | Publish/verify the snapshot chain and run a public OSV canary without persisting dependency names beyond evidence policy. | +| AI-assisted commands | Mocked boundary plus bounded live BYOK/local-provider rehearsal | Factories, routers, decorators, cost controls, explanation/refactor, and RAG tests are deterministic. | OpenAI, Anthropic, Gemini, OpenRouter, Ollama, and LM Studio must each be explicitly supported, skipped with reason, or removed from the release claim. | +| Telemetry | Exact disabled path plus opt-in delivery boundary | Exact artifacts prove disabled status; queue, redaction, endpoint, and collector contracts are tested. | Run an authorized live opt-in delivery rehearsal and prove opt-out leaves no queued/sent event. | +| npm ecosystem | Exact tested tarball, then public-registry install | Three-OS Node 22/24 npm smoke and Linux npm/pnpm/Yarn/Bun consumer contracts are encoded in CI. | Publish RC with OIDC/provenance, redownload it, compare digest, and run public `next` canaries for 24 hours. | +| Native GitHub release | Signed/notarized exact executable and immutable redownload | Five-host unsigned SEA build/smoke, deterministic archive, manifest, checksum, SBOM, signature, and attestation contracts exist. | Provider signing must succeed, every draft asset must redownload identically, and the release must become immutable. | +| PyPI/pip/pipx | Wheel containing the exact signed SEA executable | Wheel construction, platform tags, launcher integrity, and release-manifest binding have contract tests. | TestPyPI then PyPI OIDC publication and pip/pipx install/invoke/uninstall must pass on all supported hosts. | +| Shared Homebrew/Scoop catalog | Generated projection bound to the immutable release manifest | Empty bootstrap and atomic formula/manifest/lock generation and validation are implemented. | The first stable catalog PR must pass native install/upgrade/invoke/uninstall and GuardScan must verify the merged lock callback or reconciliation result. | +| WinGet | Generated portable manifest plus upstream state | Rendering, validation commands, and append-only submitted/accepted/verified states are implemented. | Local manifest install must pass, then upstream PR acceptance and public-catalog install must be observed. | +| Chocolatey | Deterministic `.nupkg` plus moderated public state | Renderer, local validation command, and append-only moderation states are implemented. | Local-feed lifecycle must pass, then validation, verification, VirusTotal, moderation, and public install must complete. | +| RC soak and promotion | Public canaries, unchanged PR head, no incident, 24-hour decision | Promotion policy and reconciliation contracts are tested. | At least 24 hourly green samples per required channel and a complete 24-hour wall-clock window are mandatory. | +| Rollback/forward fix | Append-only recovery evidence | Withdrawal, supersession, catalog correction, yanking/deprecation, and forward-fix planning are represented in contracts. | Rehearse a partial-publication failure before stable; never overwrite an immutable artifact. | +| Homebrew Core | Separately authorized submission and public-Core canary | A renderer/validator exists, but Core is not selected by the current release train. | A reviewed enablement change, Core submission/acceptance, and `brew install guardscan` public canary are required. It never blocks `1.1.0`. | + +## Install and runtime variants + +| Variant | Runtime contract | Current acceptance target | Current launch gate | +| --- | --- | --- | --- | +| Source checkout / `npm link` | Developer-only Node runtime; not a public artifact | Unit, integration, compiled CLI, lint, typecheck, and release contracts | Cannot substitute for packed/public artifact evidence. | +| npm global | Exact tarball; Node 22 or newer | Linux, macOS, Windows on Node `22.23.1` and `24.18.0` | OIDC/provenance publish, digest redownload, and public RC/stable lifecycle. | +| pnpm global/dlx | Same npm tarball and Node requirement | Pinned pnpm `10.34.0` package-consumer smoke | Public registry global/dlx install, invoke, and uninstall. | +| Yarn Classic global / Yarn Modern dlx | Same npm tarball and Node requirement | Yarn `1.22.22` and `4.9.2` package-consumer smoke | Public registry lifecycle for both generations. | +| Bun global/bunx | Same npm tarball; GuardScan still requires Node 22 | Bun `1.3.14` package-consumer smoke | Public registry global/bunx lifecycle with Node present. | +| SEA archives | Embedded pinned Node runtime | Linux glibc x64/arm64, macOS x64/arm64, Windows x64 | Current-SHA signing/notarization, attestation, immutable release, and public redownload. | +| PyPI wheels | Exact signed SEA plus standard-library launcher | `manylinux_2_28_x86_64`, `manylinux_2_28_aarch64`, `macosx_11_0_x86_64`, `macosx_11_0_arm64`, `win_amd64` | TestPyPI/PyPI OIDC and pip/pipx native lifecycles. | +| First-party Homebrew tap | Signed SEA selected by host CPU/OS | Four macOS/Linux targets from the shared catalog | Stable catalog merge and public tap lifecycle. | +| Scoop, WinGet, Chocolatey | Signed Windows x64 SEA | One manifest/package per catalog, all bound to the same release digest | Public catalog acceptance and clean Windows lifecycle. | +| Homebrew Core | Source build with Homebrew `node` dependency | Not selected | Separate reviewed enablement and public-Core verification. | + +## Release decision rule + +A command is launch-accepted only when its required evidence is present for the +exact selected version and every install/runtime variant that claims it. A +command-specific partial result does not block unrelated development, but it +does block any public claim that the command is verified on that variant. + +Stable release completion additionally requires every selected distribution +channel to materialize as `verified` in the append-only ledger. Moderated +channels may remain `submitted`, but the release is then still incomplete. +Homebrew Core is not selected and therefore is neither a current install promise +nor a `1.1.0` completion gate. diff --git a/docs/RELEASE_AUTOMATION.md b/docs/RELEASE_AUTOMATION.md index 4eab0e2..4244b5f 100644 --- a/docs/RELEASE_AUTOMATION.md +++ b/docs/RELEASE_AUTOMATION.md @@ -4,6 +4,13 @@ GuardScan uses one RC-first, append-only release train for npm, standalone GitHu The automation is fail-closed. A tag is an identity created by the release train, never publication authority. General CI has no tag trigger, publication permission, registry command, or GitHub release job. +[`FUNCTIONAL_ACCEPTANCE.md`](./FUNCTIONAL_ACCEPTANCE.md) maps every public +command and major workflow to its strongest evidence class, install/runtime +variants, and remaining launch gate. [`RELEASE_ONBOARDING.md`](./RELEASE_ONBOARDING.md) +defines the one-time default-branch bootstrap and provider activation order. +Neither an implemented workflow nor a green component test is public-release +evidence until it is bound to the selected commit and exact artifact. + ## Release invariants - `cli/package.json`, `cli/package-lock.json`, `cli/CHANGELOG.md`, the tag, and the exact commit agree. @@ -15,6 +22,18 @@ The automation is fail-closed. A tag is an identity created by the release train - WinGet and Chocolatey remain `submitted` until their public catalogs accept them and a clean public installation passes. - Rollback never mutates history or overwrites a release. It appends recovery events and prepares a forward-fix patch. +## Selected channels + +The current RC train selects npm, its pnpm/Yarn/Bun consumer canaries, GitHub +native assets, Homebrew tap preview, Scoop preview, and PyPI. The stable train +selects those channels plus WinGet and Chocolatey. Every selected channel must +reach `verified` before the stable release is complete. + +Homebrew Core is not selected. The renderer and validator are dormant building +blocks only: the orchestrator calls `releaseTrainChannels` without the explicit +`homebrewCoreEnabled` option, so it cannot submit Core in the current train. +Core requires a separate reviewed enablement and remains nonblocking. + ## Workflow ownership | Workflow | Authority | @@ -161,7 +180,8 @@ The builder bundles one CommonJS program, allows only `tiktoken` and `chartjs-no - offline static scanning; - SPDX 2.3 and CycloneDX 1.7; - telemetry-disabled status; -- safe reduced capability behavior. +- safe reduced capability behavior, proven by invoking + `guardscan capabilities --json` from the exact standalone executable. The standalone profile reports: @@ -210,7 +230,10 @@ The repository also contains: ## RC and promotion -Start the first candidate after provider onboarding: +Before the first candidate, merge the inert automation bootstrap to `main` +while `RELEASE_AUTOMATION_ENABLED=false`, complete every onboarding check, and +verify that the release PR head and full gate are unchanged. Then set the +variable to `true` and start the candidate from the default-branch workflow: ```bash gh workflow run release-train.yml \ @@ -255,17 +278,17 @@ pipx install guardscan-cli The npm package requires Node 22 or newer even when invoked by Bun. The standalone and wheel channels include the runtime. -The one-part command `brew install guardscan` becomes available only after -GuardScan is accepted into Homebrew Core and passes a clean public-Core canary. -That optional path is submitted after a stable release, does not block release -completion, and uses a source-building Core formula with Homebrew's `node` -dependency and `std_npm_args`. Until acceptance, documentation keeps -`brew install ntanwir10/tap/guardscan` as the primary command; afterward, the -first-party tap remains the supported fallback. - -The optional `homebrew-core` channel uses the normal append-only -`submitted -> accepted -> verified` states. Submission is not acceptance, and -acceptance is not public verification. +The one-part command `brew install guardscan` is **not selected or advertised by +the current train**. It becomes available only after a separately reviewed +change enables the optional `homebrew-core` channel, a source-building formula +is submitted and accepted, and a clean public-Core canary reaches `verified`. +Until then, `brew install ntanwir10/tap/guardscan` is the only supported Homebrew +contract. + +If enabled later, `homebrew-core` uses the normal append-only +`submitted -> accepted -> verified` states. Submission is not acceptance, +acceptance is not public verification, and Core never blocks first-party release +completion. ## Recovery diff --git a/docs/RELEASE_ONBOARDING.md b/docs/RELEASE_ONBOARDING.md index d97a581..a55a507 100644 --- a/docs/RELEASE_ONBOARDING.md +++ b/docs/RELEASE_ONBOARDING.md @@ -2,15 +2,53 @@ The repository contains the zero-touch release implementation. The following provider-owned identity and account steps must be completed once before `1.1.0-rc.1`. Automation must not fabricate or bypass them. +The release remains closed until the evidence in +[`FUNCTIONAL_ACCEPTANCE.md`](./FUNCTIONAL_ACCEPTANCE.md) is satisfied for the +candidate commit and artifacts. + +## Activation order while automation is off + +Use this order for the first train: + +1. Reauthenticate the maintainer and set the GuardScan repository variable + `RELEASE_AUTOMATION_ENABLED=false` **before** merging release automation to + the default branch. +2. Land a reviewed bootstrap PR on `main` containing the inert release + workflows, schemas, renderer, and ledger seed. It must not change a public + version, create a tag, or publish an artifact. +3. Confirm GitHub lists the workflows from `main`, CI passes, Release Please is + skipped, scheduled train jobs are skipped, and publication jobs reject the + disabled state. A manual canary validation may run read-only checks, but it + cannot authorize publication or a ledger transition. +4. Complete the GitHub App, branch/tag protections, environments, catalog, OIDC + publishers, signing identities, moderated-registry accounts, and monitoring + below. Keep the automation variable false throughout. +5. Recheck that the protected `release/1.1.0` PR head is unchanged and that its + complete release gate passes. The bootstrap merge to `main` is not release + approval. +6. Set `RELEASE_AUTOMATION_ENABLED=true` only when every checklist item and + provider rehearsal is complete, then dispatch exactly the candidate command + in `RELEASE_AUTOMATION.md`. + +GitHub only accepts manual and scheduled workflow execution from workflow files +present on the default branch. Keeping the implementation solely on the release +PR would therefore leave the orchestrator unavailable; the inert bootstrap is a +required first-release exception. + ## GitHub -- Reauthenticate `gh` as `ntanwir10`. -- Create `ntanwir10/homebrew-tap` as the public shared Homebrew/Scoop catalog. -- Create `guardscan-release-bot` as a GitHub App. +- Reauthenticate `gh` as `ntanwir10` and verify the active host/account before + any repository mutation. +- Create or verify `ntanwir10/homebrew-tap` as the public shared Homebrew/Scoop + catalog. +- Create `guardscan-release-bot` as a GitHub App owned by `ntanwir10`. - Grant the App Actions, Contents, Pull requests, Issues, and Workflows **write** permission plus Metadata **read** permission. -- Install the App only on GuardScan and `ntanwir10/homebrew-tap`. -- Store `RELEASE_APP_ID` as a repository variable and `RELEASE_APP_PRIVATE_KEY` as a secret. +- Install the App only on GuardScan and `ntanwir10/homebrew-tap`; do not grant + organization-wide or all-repository access. +- Store `RELEASE_APP_ID` as a repository variable and + `RELEASE_APP_PRIVATE_KEY` as a secret in each installed repository. The key is + exchanged only for short-lived installation tokens; there is no PAT fallback. - Seed the orphan `release-ledger` branch from `.github/release-ledger/active-versions.json`, then protect the branch and require the App identity for writes. Do not copy application source onto the @@ -19,9 +57,9 @@ The repository contains the zero-touch release implementation. The following pro - Enable immutable releases for GuardScan. - Enable squash merge and auto-merge, and require the full `Release gate` status on the stable release PR. -- Set repository variable `RELEASE_AUTOMATION_ENABLED=false` until every - onboarding rehearsal below passes. Scheduled reconciliation and canaries - must remain dormant while it is false. +- Keep repository variable `RELEASE_AUTOMATION_ENABLED=false` until every + onboarding rehearsal below passes. Scheduled reconciliation and automatic + canaries remain dormant while it is false. Create environments without manual reviewers: @@ -40,8 +78,12 @@ where an environment needs them. Fork pull requests must not receive environment secrets or OIDC tokens. The reusable build and publish workflows run in the security context of their -caller. Environment and OIDC policies therefore identify -`.github/workflows/release-train.yml`, not the called reusable workflow. +caller. In npm and PyPI provider forms, enter the workflow **filename** +`release-train.yml` (not a path), which identifies the caller; do not register +the reusable `release-publish.yml`. PyPI does not accept a reusable workflow as +the trusted-publisher workflow. See the current +[npm trusted-publisher fields](https://docs.npmjs.com/trusted-publishers/) and +[PyPI reusable-workflow limitation](https://docs.pypi.org/trusted-publishers/troubleshooting/#reusable-workflows-on-github). ## Shared Homebrew and Scoop catalog @@ -82,19 +124,29 @@ generated projection. ## npm - Configure trusted publishing for package `guardscan`. -- Bind it exactly to `ntanwir10/GuardScan`, - `.github/workflows/release-train.yml`, and environment `npm-publish`. -- Confirm the release job installs its pinned npm version at `11.5.1` or newer - before the trusted-publishing rehearsal; the npm bundled with Node 22 is not - sufficient for this contract. +- Bind it exactly to GitHub user `ntanwir10`, repository `GuardScan`, workflow + filename `release-train.yml`, environment `npm-publish`, and allowed action + `npm publish`. Do not allow staged publication unless the release train is + separately changed to use it. +- Confirm the release job installs and verifies exactly npm `11.5.2` before the + trusted-publishing rehearsal; the npm bundled with Node 22 is not the release + identity. +- Confirm the publisher receives an OIDC identity token only in the + `npm-publish` job and publishes the previously tested tarball with + `--provenance`. The job must redownload registry metadata and reject a digest + conflict before recording success. - Do not retain an npm token fallback after OIDC succeeds. ## TestPyPI and PyPI - Reserve `guardscan-cli`. - Configure pending trusted publishers for both TestPyPI and PyPI. -- Bind them exactly to `ntanwir10/GuardScan`, - `.github/workflows/release-train.yml`, and environment `pypi`. +- Bind them exactly to GitHub owner `ntanwir10`, repository `GuardScan`, + workflow filename `release-train.yml`, and environment `pypi`. +- Confirm both publishers accept the PEP 440 identity `1.1.0rc1` derived from + tag `v1.1.0-rc.1`. TestPyPI must converge to all five tested wheels and pass + pip/pipx native lifecycles before the production PyPI job can run. +- Do not configure passwords or API-token fallbacks in the `pypi` environment. ## Apple @@ -109,9 +161,17 @@ Enroll the publisher and provision: Store them only in `apple-notarization`. Renewals and Apple identity revalidation remain external authority boundaries. +Import the certificate into an ephemeral keychain during the macOS job. Verify +the Developer ID Application subject includes `APPLE_TEAM_ID`; submit with +`notarytool`, inspect the accepted log, staple, and require both `codesign` and +`spctl` verification before archiving. Delete the keychain and temporary key +material at job cleanup. + ## Azure Artifact Signing -Create a Public Trust signing account/profile and GitHub OIDC federation. Configure these environment variables in `windows-signing`: +Create a Public Trust signing account/profile and GitHub OIDC federation. +Configure these GitHub environment **variables** (not secrets) in +`windows-signing`: - `AZURE_TENANT_ID` - `AZURE_SUBSCRIPTION_ID` @@ -122,15 +182,47 @@ Create a Public Trust signing account/profile and GitHub OIDC federation. Config Grant only the Artifact Signing Certificate Profile Signer role needed by the federated identity. +Set the GitHub OIDC federated credential subject exactly to +`repo:ntanwir10/GuardScan:environment:windows-signing`. The environment's +protected-branch policy and the repository's protected workflows constrain the +caller separately. The job must verify Authenticode status and timestamp before +the executable is archived. Do not add an Azure client secret fallback. + ## WinGet and Chocolatey - Accept the Microsoft CLA for the submitting identity. -- Store a narrowly scoped `WINGET_GITHUB_TOKEN` in `winget`. +- Store a narrowly scoped, expiry-bounded `WINGET_GITHUB_TOKEN` in `winget`. + It may submit the generated manifests to `microsoft/winget-pkgs`; it must not + have GuardScan administration or release authority. - Create/validate the Chocolatey publisher account. - Store `CHOCO_API_KEY` in `chocolatey`. +Rehearse WinGet local-manifest install before submission and Chocolatey +install/upgrade/invoke/uninstall from a local feed before `choco push`. A +successful submission is not deployment. + WinGet review and Chocolatey validation, verification, VirusTotal, and moderation are external states. The ledger keeps them `submitted` until public installation passes. +## Optional Homebrew Core + +Homebrew Core is **not selected** for the current release train. Do not submit a +Core formula and do not advertise `brew install guardscan` during `1.1.0`. +Users install from the first-party tap until a separate reviewed enablement is +merged and verified. + +Enabling Core later requires all of the following: + +1. a reviewed change that explicitly adds `homebrew-core` to the selected + stable channels and ledger policy; +2. a source-building formula that meets current Homebrew Core policy and passes + local style, audit, build, test, and uninstall checks; +3. submission to Homebrew Core, external acceptance, and a clean public install + canary for the one-part command; and +4. documentation changes only after the public canary reports `verified`. + +Core remains nonblocking for first-party release completion even after an +optional submission is tracked. + ## First `1.1.0` bootstrap exception The first stable train does not ask Release Please to regenerate `1.1.0`. @@ -143,16 +235,28 @@ so subsequent stable release PRs follow the normal automated path. ## Expiry monitoring -Configure provider notifications for: - -- GitHub App key age and installation loss; -- Apple certificate/notary key expiry; -- Azure federation/profile health; -- Chocolatey API key validity; -- WinGet token expiry or revoked CLA status. - -After all rehearsals pass, set `RELEASE_AUTOMATION_ENABLED=true`. No later -release requires a human promotion click. Only provider-mandated identity, MFA, -legal, certificate-renewal, account verification, or moderator requests remain -human boundaries; automation must report those states rather than claiming -completion. +Credential health is a launch gate, not an informal maintainer reminder. +Configure alerts to a monitored release-owner destination and record only +status/expiry metadata, never secret values. + +| Identity/binding | Automated signal | Alert/rehearsal policy | +| --- | --- | --- | +| GitHub App | Installation exists on exactly both repositories; required permissions remain; token mint succeeds | Check daily and before every candidate. Alert immediately on installation/permission drift and at the configured private-key age limit. | +| npm trusted publisher | Repository, caller workflow, and `npm-publish` environment match; no token fallback exists | Check before every candidate and monthly. A mismatch keeps automation off. | +| TestPyPI/PyPI trusted publishers | Project, repository, caller workflow, and `pypi` environment match | Check both services before every candidate and monthly; rehearse TestPyPI before production. | +| Apple certificate/notary identity | Certificate subject/team, expiry, and notary authentication are valid | Check daily; alert at 60, 30, 14, and 7 days. Renewal/identity revalidation requires the publisher. | +| Azure federation/signing profile | Federated subject, signer role, account/profile state, and timestamp service are healthy | Check weekly and before every candidate with a non-secret signing preflight. Alert immediately on role or federation drift. | +| WinGet submitter | Token expiry/scope and Microsoft CLA state are valid | Check weekly; alert at 30, 14, and 7 days. CLA or account challenges remain external authority boundaries. | +| Chocolatey publisher | Account/package ownership and API-key authentication remain valid | Check weekly with a non-publishing endpoint; alert at 30, 14, and 7 days when expiry metadata is available. | +| Shared catalog connection | App installation, required check, dispatch permission, and scheduled reconciliation are healthy | Check daily. A missed dispatch is recovered by reconciliation; an invalid lock/digest is an integrity incident. | + +If the provider does not expose expiry metadata, use a bounded authentication +preflight and record `unknown` rather than inventing a date. Monitoring is not +complete until at least one alert path has been tested. + +After every onboarding item, functional acceptance prerequisite, and monitoring +rehearsal passes, enable the variable and immediately dispatch the first +candidate. No later release requires a human promotion click. Only +provider-mandated identity, MFA, legal, certificate-renewal, account +verification, or moderator requests remain human boundaries; automation must +report those states rather than claiming completion. diff --git a/docs/adrs/006-node-sea-standalone-distribution.md b/docs/adrs/006-node-sea-standalone-distribution.md index 4ee0e78..8c5c583 100644 --- a/docs/adrs/006-node-sea-standalone-distribution.md +++ b/docs/adrs/006-node-sea-standalone-distribution.md @@ -2,7 +2,12 @@ ## Status -Proposed +Accepted + +Acceptance records the architecture and its host-native feasibility evidence. It +does not authorize publication by itself: production artifacts remain gated by +exact-source builds, platform signing/notarization, provenance, and release +verification. ## Date @@ -35,7 +40,7 @@ Each target is built on its native hosted runner: - Linux x64 and arm64 glibc on Linux runners; - Windows x64 on a Windows runner. -The first prototype is host-platform only. It emits explicitly non-publishable prototype metadata and cannot be consumed by adapter rendering. Production artifact metadata is generated only after archive reproducibility, platform signing, provenance, and the full standalone smoke contract pass. +The host-platform feasibility stage emits explicitly non-publishable prototype metadata and cannot be consumed by adapter rendering. A separate production artifact stage generates publishable metadata only after archive reproducibility, platform signing, provenance, and the full standalone smoke contract pass. `tiktoken` and `chartjs-node-canvas` remain external optional capabilities. The standalone executable reports token estimates and omits chart images when those modules are unavailable. Core static scanning, dependency inventory, vulnerability snapshot use, and SPDX/CycloneDX SBOM generation remain required capabilities. @@ -58,7 +63,7 @@ The alternatives were rejected for the initial implementation: ### Positive -- Native package managers can eventually install one immutable executable without Node. +- Native package managers can install one immutable executable without Node after the production launch gates pass. - npm and standalone channels retain one implementation and version. - Every target is built and tested where its signing and runtime behavior can be observed. - Optional native modules cannot block core standalone startup. @@ -79,18 +84,27 @@ The alternatives were rejected for the initial implementation: - The builder fails on unresolved required imports and externalizes only an explicit allowlist of optional native packages. - Prototype output includes source version, commit, platform, architecture, Node runtime, bundle and executable SHA-256, size, capabilities, and `productionReady: false`. - Smoke tests run `--version`, `--help`, an offline static-only scan, SBOM generation, and telemetry status in an isolated home. `PATH` excludes Node and package managers. -- Release archives are generated in a later work item with normalized paths, modes, ownership, timestamps, and ordering. +- The production artifact stage generates release archives with normalized paths, modes, ownership, timestamps, and ordering, then inspects their structure and digest before they can enter a release manifest. - Production manifests require exact versioned GitHub Release URLs, checksums, signature evidence, and provenance. Adapters cannot render from prototype metadata. - macOS signing/notarization and Windows signing happen after injection. Release publication fails if signature verification is unavailable or incomplete. -## Acceptance before status changes to Accepted +## Implementation evidence and production launch gates + +The host-native CI feasibility matrix passes on all five supported targets, and +the implementation enforces the bundle allowlist, Node-free `PATH`, core static +scan, both SBOM formats, telemetry-disabled state, deterministic archive +structure, and production-evidence boundary. That evidence is sufficient to +accept Node SEA as the distribution architecture. + +Every release must still pass these launch gates before any artifact is marked +or advertised as production-ready: -- The exact prototype passes on every supported OS/architecture target. -- Required CLI commands pass with Node absent from `PATH`. -- Bundle analysis confirms there are no undeclared runtime filesystem dependencies. -- Optional modules degrade according to the documented capability profile. -- Production archive reproducibility and archive extraction safety are proven. -- macOS notarization, Authenticode, checksums, SBOMs, and provenance are verified against exact release artifacts. +- Build and test each executable from the exact protected tag on its native host. +- Confirm required CLI commands pass with Node and package managers absent from `PATH`. +- Confirm bundle analysis has no undeclared runtime or filesystem dependency. +- Exercise missing optional modules and verify the documented reduced capability profile. +- Prove production archive reproducibility and archive extraction safety. +- Verify macOS notarization, Windows Authenticode, Linux Sigstore evidence, checksums, SBOMs, attestations, and provenance against the exact downloadable artifacts. ## Related decisions @@ -105,6 +119,7 @@ The alternatives were rejected for the initial implementation: ## Review -Review after the host-native CI feasibility matrix completes. +Review after the first signed stable release, or whenever the Node, esbuild, +postject, target-platform, or signing contract changes. **Next review date**: 2026-08-15 diff --git a/tasks/plan.md b/tasks/plan.md index 764d94a..e6ea815 100644 --- a/tasks/plan.md +++ b/tasks/plan.md @@ -1,5 +1,454 @@ # GuardScan multi-channel distribution and launch plan +## Active execution plan β€” v1.1.0 release closure (2026-08-02) + +### Objective + +Move GuardScan from a clean, CI-green `release/1.1.0` implementation branch to +an operational RC-first release train that publishes `1.1.0-rc.1`, observes it +for 24 hours, and promotes the unchanged stable source to `1.1.0` across npm, +GitHub Releases, the shared Homebrew/Scoop catalog, PyPI, WinGet, and +Chocolatey. Homebrew Core remains an optional discovery channel and must never +block the first-party Homebrew tap or the stable release. + +The plan separates repository work from external authority boundaries. Code, +tests, workflow definitions, documentation, and bootstrap branches are +automatable. Account creation, legal agreements, trusted-publisher approval, +Apple identity, Azure identity, and registry moderation require provider-owned +state and cannot be simulated by repository changes. + +### Current baseline + +- Source: clean `release/1.1.0` at `d94a07a5cf9c2bf06723cdcbb3ef80f3d6982636`. +- Review: PR #32 is open and draft; its exact-head 27-job CI run is green. +- Public state: npm and GitHub stop at `1.0.5`; every new 1.1.0 channel is + unpublished. +- Automation: release workflows exist only on the release branch, while + GitHub requires dispatch and scheduled workflows to exist on the default + branch. `RELEASE_AUTOMATION_ENABLED` is intentionally false. +- Provider state: release environments exist but are empty; the GitHub App, + signing identities, moderated-registry credentials, and trusted publishers + are not proven. The current local GitHub CLI credential is invalid. +- Shared catalog: `ntanwir10/homebrew-tap` is public, protected, and healthy in + its intentionally empty bootstrap state. + +### Architecture and operating decisions + +1. Keep `release/1.1.0` as the stable product source. Land inert workflow + definitions on `main` through a separate bootstrap PR, then merge that + bootstrap commit back into the release branch before deriving the RC. +2. Keep automation disabled until every release environment passes a + credential/preflight rehearsal. Tags alone never authorize publication. +3. Use the exact tested npm tarball for npm, pnpm, Yarn, and Bun. These clients + are compatibility channels, not independent publications. +4. Use the exact signed standalone executable for GitHub, the shared catalog, + WinGet, Chocolatey, and the platform wheels published to PyPI. +5. Keep Homebrew and Scoop in one generated, cryptographically locked catalog. + GuardScan is authoritative; the catalog is a projection and reports back by + signed dispatch plus scheduled reconciliation. +6. Treat Homebrew Core as optional. Include it in release state only when an + explicit provider setting enables submission. Never advertise + `brew install guardscan` until Core acceptance and a public canary succeed. +7. Treat WinGet and Chocolatey `submitted`, `accepted`, and `verified` as + different append-only states. Expected moderation delay is pending, not + failure. +8. Use tests first for behavioral fixes. Workflow text/structure tests must + reproduce each release bug before the YAML or release logic is changed. +9. Keep the Cloudflare retirement downstream of verified stable publication: + seven days of `410 Gone`, then deletion, with no collection or redirect. + +### Dependency graph + +```text +Release-content truth (changelog, docs, ADR, command contracts) + | + +--> Workflow correctness (PR readiness, per-version canaries, + | moderation states, native TestPyPI lifecycle) + | | + | +--> Default-branch workflow bootstrap + | | + | +--> GitHub App and repository protections + | +--> npm/PyPI trusted publishers + | +--> Apple/Azure signing + | +--> WinGet/Chocolatey publisher onboarding + | | + | +--> v1.1.0-rc.1 publication + | | + | +--> 24-hour canary evidence + | | + | +--> v1.1.0 promotion + | | + | +--> moderated acceptance + | +--> Homebrew Core optional PR + | +--> Cloudflare retirement + +--> Whole-product acceptance expansion -----------------------^ +``` + +### Phase 1 β€” release-content and product-contract truth + +#### Task 1.1 β€” Reconcile the 1.1.0 changelog + +**Description:** Move every 1.1.0-bound entry out of `Unreleased`, merge it into +one authoritative `1.1.0` section, and harden source validation so a stable +release cannot pass with populated release notes stranded above its version. + +**Acceptance criteria:** + +- One stable `1.1.0` section contains all release changes, including hosted + telemetry retirement. +- Candidate derivation adds only RC identity and does not duplicate stable + release notes. +- A focused regression test fails for the former split-changelog structure. + +**Verification:** `npm run test:release`; `npm run release:validate`. + +**Dependencies:** None. **Scope:** Medium, 3-4 files. + +#### Task 1.2 β€” Correct public command syntax and command inventory + +**Description:** Align README and quick-start examples with the actual +Commander definitions for `review`, `docs`, `test-gen`, and `refactor`, and add +a contract test that checks documented invocations against `--help` output. + +**Acceptance criteria:** + +- No documented positional argument is accepted only in prose. +- The documented 29-command inventory matches registered commands. +- Documentation tests fail if command syntax drifts again. + +**Verification:** focused documentation/CLI test; full build and tests. + +**Dependencies:** None. **Scope:** Small, 2-3 files. + +#### Task 1.3 β€” Promote the standalone ADR from proposal to accepted evidence + +**Description:** Define the exact evidence required to change ADR 006 from +`Proposed` to `Accepted`. Repository feasibility can accept the architecture; +production signing evidence remains an explicit launch gate. + +**Acceptance criteria:** + +- ADR status and evidence distinguish architectural acceptance from provider + rehearsal. +- Reduced standalone capabilities remain explicit and machine-readable. +- Documentation never claims chart rendering or accurate tokenization in SEA. + +**Verification:** release documentation contract tests and review. + +**Dependencies:** Task 1.1. **Scope:** Small, 2 files. + +#### Task 1.4 β€” Expand whole-product acceptance evidence + +**Description:** Replace placeholder RAG assertions with deterministic local +index/search/chat tests, and maintain an acceptance matrix that separates +offline core behavior, optional external tools, mocked BYOK providers, and live +provider rehearsals. + +**Acceptance criteria:** + +- RAG tests exercise real repository indexing and retrieval with deterministic + local doubles only at the network/provider boundary. +- Every advertised command has more than a help-only contract or is explicitly + classified as requiring an external tool/provider. +- No CI test requires paid API credentials or uploads source code. + +**Verification:** focused RAG/command tests; full coverage suite. + +**Dependencies:** Task 1.2. **Scope:** split into multiple medium follow-ups. + +### Phase 2 β€” release workflow correctness + +#### Task 2.1 β€” Make canary execution version-safe + +**Description:** Stop selecting the first active train as a shared checkout. +Use default-branch release tooling for ledger recording while every matrix item +continues to verify its own immutable public version. + +**Acceptance criteria:** + +- Two simultaneous active trains cannot use one another's tag as tooling. +- Manual single-version canaries remain supported. +- Workflow contract tests reproduce and prevent the former first-train bug. + +**Verification:** release workflow tests and YAML parse. + +**Dependencies:** Phase 1 plan only. **Scope:** Small, 2 files. + +#### Task 2.2 β€” Preserve pending moderation states + +**Description:** Make WinGet and Chocolatey absence during normal review an +explicit pending result. Only install, signature, checksum, or invocation +failures after discovery may produce `failed`. + +**Acceptance criteria:** + +- β€œPackage not public yet” remains pending. +- A discovered package that fails installation/invocation is failed. +- Reconciliation keeps polling pending channels without opening an incident. + +**Verification:** workflow regression test plus release-state unit tests. + +**Dependencies:** None. **Scope:** Small, 2-3 files. + +#### Task 2.3 β€” Add idempotent moderated-registry submission evidence + +**Description:** Capture WinGet PR identity and Chocolatey package submission +identity, distinguish matching prior submissions from integrity conflicts, and +persist these identities in the ledger. + +**Acceptance criteria:** + +- Rerunning a matching submission records/reuses the same identity. +- Conflicting remote content stops as an integrity incident. +- WinGet submission and catalog acceptance are separate states. + +**Verification:** mocked remote-contract tests and workflow text contracts. + +**Dependencies:** Task 2.2. **Scope:** Medium, 4-5 files. + +#### Task 2.4 β€” Test every TestPyPI wheel natively before PyPI + +**Description:** After TestPyPI publication, install the exact version with pip +and pipx on Linux x64/arm64, macOS x64/arm64, and Windows x64. Production PyPI +must depend on the complete matrix. + +**Acceptance criteria:** + +- All five platform tags select and run their intended executable. +- Version, help, offline scan, and uninstall pass for pip and pipx. +- PyPI publication cannot start if any native TestPyPI lifecycle fails. + +**Verification:** workflow structure tests, then hosted RC rehearsal. + +**Dependencies:** Task 2.1. **Scope:** Medium, 2 files. + +#### Task 2.5 β€” Exercise reduced standalone capabilities + +**Description:** Add a deterministic runtime capability diagnostic and smoke +that actually invokes token-count estimation and chart-unavailable degradation +with optional native modules absent. + +**Acceptance criteria:** + +- SEA reports `accurateTokenCounting=false` and demonstrates estimated token + counting without `tiktoken`. +- A report path that requests charts completes safely without + `chartjs-node-canvas` and records the reduced capability. +- Artifact metadata is derived from the observed smoke, not an unconditional + boolean. + +**Verification:** failing focused tests first, local package tests, five hosted +SEA jobs. + +**Dependencies:** Task 1.3. **Scope:** Medium, 4-5 files. + +#### Task 2.6 β€” Make release-PR readiness zero-touch and fail closed + +**Description:** Candidate preparation must inspect the exact PR, mark the +bot-owned release PR ready when allowed, and fail if it is closed, from a fork, +or no longer targets `main`. Promotion still requires unchanged head and all +required checks. + +**Acceptance criteria:** + +- The current draft state cannot silently deadlock promotion. +- Untrusted/fork PRs never receive release credentials or tags. +- Ready state, base branch, head SHA, and mergeability are recorded. + +**Verification:** workflow contract tests and a non-publishing GitHub rehearsal. + +**Dependencies:** Tasks 2.1-2.5. **Scope:** Small, 2 files. + +#### Task 2.7 β€” Make Homebrew Core honestly optional + +**Description:** Remove phantom planned state when Core submission is not +configured. If enabled, render, validate, submit, record PR identity, poll +acceptance, and verify public installation without blocking required channels. + +**Acceptance criteria:** + +- Stable completion is possible with the first-party tap while Core is off. +- When on, Core progresses `submitted -> accepted -> verified` with evidence. +- User docs expose `brew install guardscan` only after verification. + +**Verification:** release-state tests, renderer tests, workflow contracts. + +**Dependencies:** Tasks 2.2-2.3. **Scope:** split into medium implementation +and external onboarding tasks. + +### Phase 3 β€” default-branch bootstrap and repository controls + +#### Task 3.1 β€” Create an inert workflow-bootstrap change + +**Description:** Prepare a branch from `origin/main` containing the reusable +release workflows and their default-branch entrypoints, with automation still +off. Do not merge the product release or create a release tag. + +**Acceptance criteria:** + +- GitHub lists the train, canary, build, publish, and Release Please workflows + on `main` after merge. +- Schedules and dispatches are inert while the repository variable is false. +- Bootstrap CI passes and no registry receives a publication request. + +**Verification:** PR checks, GitHub workflow listing, dry dispatch rejection. + +**Dependencies:** Phase 2 complete. **Scope:** remote Git/GitHub operation. + +#### Task 3.2 β€” Finish GitHub App and protection setup + +**Description:** Install `guardscan-release-bot` on GuardScan and the shared +catalog; configure short-lived token inputs; protect release tags and restrict +ledger writes to the App while preserving break-glass recovery. + +**Acceptance criteria:** + +- No long-lived general-purpose token publishes a release. +- Release tags and ledger writes are limited to the intended identity. +- Catalog notifications authenticate cross-repository and are idempotent. + +**Verification:** permission-negative tests from a fork and positive dry runs. + +**Dependencies:** Valid GitHub authentication and Task 3.1. + +#### Task 3.3 β€” Merge bootstrap truth back into the stable release PR + +**Description:** Merge `main` into `release/1.1.0`, resolve only release-owned +files, rerun the exact source gate, and confirm PR #32 remains unchanged after +the final approved head is selected. + +**Acceptance criteria:** clean worktree, green exact-SHA CI, PR ready for +review, no unreviewed product change after RC derivation. + +**Verification:** full local gate and hosted CI. + +**Dependencies:** Tasks 3.1-3.2. + +### Phase 4 β€” provider onboarding and production rehearsals + +#### Task 4.1 β€” npm and PyPI trusted publishing + +Configure exact workflow/environment bindings for npm, TestPyPI, and PyPI; +rehearse OIDC issuance; verify provenance; remove the legacy `NPM_TOKEN` only +after OIDC succeeds. + +#### Task 4.2 β€” Apple and Windows signing + +Provision Developer ID/notary credentials and Azure Artifact Signing OIDC; +build, sign, notarize/timestamp, staple, and verify representative artifacts. + +#### Task 4.3 β€” WinGet and Chocolatey publisher authority + +Complete the Microsoft CLA and scoped GitHub credential; confirm Chocolatey +publisher ownership and API key; submit non-public validation fixtures where +the provider allows it. + +#### Task 4.4 β€” Homebrew Core optional authority + +Configure a narrowly scoped contribution credential and enable the optional +channel only after the first-party stable formula is verified. + +#### Task 4.5 β€” Credential-expiry monitoring + +Add scheduled checks for certificate dates, App installation health, OIDC +bindings, and provider identity revalidation, with alerts that contain no +secret material. + +**Phase 4 acceptance:** every required environment passes a preflight, secrets +stay out of logs/artifacts, fork PRs cannot mint tokens, and automation remains +off until the final go/no-go decision. + +### Phase 5 β€” RC, soak, stable promotion, and recovery + +#### Task 5.1 β€” Publish `v1.1.0-rc.1` + +Enable automation and dispatch the candidate from `main` using PR #32. Publish +npm `next`, an immutable GitHub prerelease, TestPyPI then PyPI `1.1.0rc1`, and +isolated shared-catalog preview branches. Render but do not submit RC WinGet or +Chocolatey packages. + +#### Task 5.2 β€” Complete the 24-hour soak + +Require at least 24 hourly samples per required channel, fresh final evidence, +unchanged source head, no high/critical dependency vulnerability, valid public +digests/signatures, and no open release/security incident. + +#### Task 5.3 β€” Promote stable `v1.1.0` + +Materialize the machine promotion decision, auto-merge the unchanged PR, +rebuild/sign/attest from the exact merge commit, publish npm `latest`, GitHub, +PyPI, the shared catalog, then submit WinGet and Chocolatey. + +#### Task 5.4 β€” Reconcile moderated channels and optional Core + +Poll external review without misclassifying delay. Mark release completion only +when every required selected channel is verified; keep optional Core evidence +separate. + +#### Task 5.5 β€” Execute post-release Cloudflare retirement + +After stable public verification, serve seven days of `410 Gone` without +collection or redirect, monitor old-client traffic only through aggregate +infrastructure signals if already available, then remove the hosted Worker and +DNS/runtime resources according to ADR 007. + +### Verification checkpoints + +#### Checkpoint A β€” repository correctness + +- Focused regression tests are red before each behavioral fix and green after. +- `npm run typecheck`, `npm run build`, `npm run test:release`, release package + verification, documentation contracts, and `git diff --check` pass. + +#### Checkpoint B β€” whole product + +- Full coverage suite passes in band. +- Production dependency audit has no high or critical result. +- Packed npm artifact and all five package-manager smokes pass. +- Offline scan, SPDX, CycloneDX, telemetry disabled, and declared optional + degradation pass from installed artifacts. + +#### Checkpoint C β€” hosted release feasibility + +- Exact-SHA 27-job or expanded CI matrix passes. +- Five signed native artifacts pass platform verification. +- Five TestPyPI wheels pass native pip/pipx lifecycles. + +#### Checkpoint D β€” public RC + +- Manifest, signatures, SBOMs, checksums, attestations, URLs, and public bytes + agree for every artifact. +- Required channel canaries remain green for 24 hours. + +#### Checkpoint E β€” stable completion + +- Every required public install command reports `1.1.0`. +- Moderated channels are tracked as pending/submitted until truly public. +- Rollback/forward-fix procedures are executable without overwriting artifacts. + +### Risks and mitigations + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Workflow bootstrap accidentally publishes | Critical | Keep automation false; no provider credentials in bootstrap environments | +| RC source changes during soak | Critical | Bind candidate metadata to PR head and fail promotion on any head change | +| Registry rerun overwrites/conflicts | Critical | Digest preflight; identical means continue, different means incident | +| Apple/Azure setup blocks native launch | High | Rehearse before enabling; never publish unsigned substitutes | +| Moderation lasts days | Medium | Separate submitted/accepted/verified; do not block already verified first-party channels | +| Homebrew Core rejects formula | Low | First-party tap remains supported; Core stays optional | +| Shared catalog drifts | High | One generated projection, lock file, protected CI, dispatch plus reconciliation | +| Optional native modules crash SEA | High | Exercise actual fallback paths and bind observed capabilities into metadata | +| Documentation overstates availability | High | Publish install commands only from verified ledger state | +| Legacy Cloudflare clients keep sending | Medium | Stable upgrade notice, seven-day 410 window, then teardown | + +### External authority boundaries + +Implementation may prepare every command and validation, but it must stop for +the owner when a provider requires identity verification, MFA, legal terms, +certificate purchase/renewal, CLA acceptance, trusted-publisher approval, or +moderator action. These are not code defects and must not be bypassed with +long-lived or over-scoped credentials. + Status: historical design record, superseded by the approved RC-first zero-touch release train. This document does not authorize a release by itself. The current operating contract is in `docs/RELEASE_AUTOMATION.md`, and provider onboarding is in `docs/RELEASE_ONBOARDING.md`. ## Implementation status β€” 2026-07-25 diff --git a/tasks/session-handoff-2026-08-02.md b/tasks/session-handoff-2026-08-02.md new file mode 100644 index 0000000..d2ac514 --- /dev/null +++ b/tasks/session-handoff-2026-08-02.md @@ -0,0 +1,125 @@ +# GuardScan 1.1.0 Release Session Handoff + +Checkpoint date: 2026-08-02 + +Branch: `release/1.1.0` + +Starting commit before this work: `d94a07a` + +## Purpose + +This checkpoint preserves the in-progress implementation of the zero-touch +GuardScan `1.1.0-rc.1` to `1.1.0` release train. It is intentionally a WIP +checkpoint: do not publish, enable release automation, or create release tags +from this commit until the remaining security fixes and full verification gate +are complete. + +## Completed in this session + +- Added the detailed execution plan and working checklist in `tasks/plan.md` + and `tasks/todo.md`. +- Tightened changelog and release-train channel contracts. +- Added real deterministic RAG end-to-end acceptance tests. +- Added a public `guardscan capabilities --json` contract and standalone + reduced-capability evidence. +- Hardened standalone artifact validation and release-manifest capability + metadata. +- Added cross-platform package-manager, TestPyPI, pip, pipx, native artifact, + and exact-version canary coverage. +- Added deterministic WinGet tooling pinning and stronger WinGet/Chocolatey + submission evidence. +- Added functional acceptance documentation and corrected public command + examples. +- Marked ADR 006 accepted while retaining signing/notarization launch gates. +- Added a credential-health workflow and its contract tests. +- Began executable rollback and forward-fix orchestration. + +## Verification completed before the final WIP edits + +- `release-workflows.test.ts`: 14/14 passing after the canary and publisher + hardening changes. +- Standalone focused tests: 33/33 passing. +- Release-contract suite: 86/86 passing before the final credential-health and + rollback edits. +- Documentation command contracts: 7/7 passing. +- Typecheck passed before the final workflow/recovery edits. + +These results are historical evidence only. The entire gate must be rerun from +this checkpoint because later edits were not covered. + +## Critical blockers found by the final audit + +1. Privileged workflow shell injection: `workflow_dispatch` inputs such as + `release_pr`, `version`, and `known_good` are interpolated directly into + shell in `release-train.yml`; the manual version path in + `release-canary.yml` has the same pattern. Move inputs through `env`, validate + them before privileged steps, and quote variables. +2. Stable publication is not resumable after the release PR has merged. Add an + idempotent resume path bound to the persisted promotion decision, merge SHA, + and stable tag. +3. Rollback/recovery can deadlock the train. Finish channel recovery, + active-version removal, forward-fix creation, and protected-ledger evidence. +4. The release ledger currently substitutes one manifest digest for provider + identities that actually have distinct npm tarball and PyPI wheel digests. + Persist and validate per-provider evidence. +5. WinGet/Chocolatey moderation can remain pending forever after rejection or + closure. Model rejected, corrected, and resubmitted states explicitly. +6. Promotion verifies the release PR head but not the soaked base/tree. Record + the base SHA and require the stable merge tree to equal the approved RC tree, + or force a new RC when `main` changes during the soak. + +## Work interrupted at checkpoint + +- The rollback/forward-fix workflow implementation was interrupted while being + edited. Review `release-train.yml`, release event/state schemas, + `events.js`, `reconcile.js`, `index.js`, and `recovery-source.js` as one unit. +- The credential-health workflow implementation and its five focused tests were + reported green, but the worker's final audit was interrupted. +- A read-only whole-release audit was interrupted after reporting the six + blockers above. +- Four placeholder assertions remain in + `cli/__tests__/integration/ai-providers-enhanced.test.ts` and should be + replaced with deterministic decorator-composition tests. +- `cli/src/core/cost-guard.ts` contains a `resetDailyBudget()` placeholder; its + product and command behavior still need review. + +## Required next sequence + +1. Read this handoff, `tasks/plan.md`, and `tasks/todo.md`; inspect `git status` + and the checkpoint diff. +2. Fix all six audit blockers before running or enabling privileged workflows. +3. Review and complete the interrupted rollback/forward-fix implementation. +4. Finish the placeholder-functionality review. +5. Run from `cli/`: + - `npm run typecheck` + - `npm run build` + - `npm run test:release` + - `npm test -- --coverage --runInBand` + - `npm run lint:ratchet` + - `git diff --check` + - `npm audit --omit=dev --audit-level=high` + - `npm run test:package` + - `npm run test:package-manager` + - `npm pack --dry-run` using a temporary npm cache if needed +6. Split the verified WIP checkpoint into reviewable implementation commits if + desired; never publish from a dirty or unverified source tree. +7. Reauthenticate GitHub CLI. At checkpoint time `gh auth status` reported an + invalid token for `ntanwir10`. +8. Bootstrap inert release automation on the default branch with automation + disabled, then complete the provider-owned onboarding steps. +9. Only after all gates and onboarding are verified, create and soak + `v1.1.0-rc.1`; promote stable from the exact approved tree after 24 hours. + +## External boundaries still outstanding + +- GitHub App installation and protected environments. +- npm and PyPI trusted publishers. +- Apple Developer signing and notarization identity. +- Azure Artifact Signing OIDC resources. +- Chocolatey publisher credentials and moderation. +- WinGet credential/CLA and upstream acceptance. +- First-party Homebrew/Scoop shared-catalog publication and hosted native + validation. + +The release is not published, the 24-hour RC soak has not started, and no +public channel should be considered verified at this checkpoint. diff --git a/tasks/todo.md b/tasks/todo.md index 6224a0b..419e321 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,5 +1,92 @@ # GuardScan distribution launch checklist +## Active v1.1.0 execution checklist β€” 2026-08-02 + +### Phase 1 β€” release truth and product contracts + +- [ ] 1.1 Merge populated `Unreleased` content into one authoritative `1.1.0` changelog section. +- [ ] 1.1 Add a regression test and validator for split stable release notes. +- [ ] 1.2 Correct README positional/option syntax for review, docs, test-gen, and refactor. +- [ ] 1.2 Add a documented-command contract test for all 28 commands. +- [ ] 1.3 Accept ADR 006 architecture with production signing evidence still gated. +- [ ] 1.4 Replace placeholder RAG assertions with deterministic indexing/retrieval behavior. +- [ ] 1.4 Classify each command as offline-proven, external-tool, mocked-provider, or live-rehearsal. + +### Phase 2 β€” workflow correctness + +- [ ] 2.1 Reproduce and fix first-active-train canary checkout selection. +- [ ] 2.2 Reproduce and fix WinGet/Chocolatey pending moderation classification. +- [ ] 2.3 Persist idempotent WinGet PR and Chocolatey submission identities. +- [ ] 2.4 Add five-platform native TestPyPI pip/pipx lifecycle jobs. +- [ ] 2.5 Exercise token-estimation and chart-unavailable SEA fallbacks. +- [ ] 2.5 Derive standalone capability evidence from observed smoke results. +- [ ] 2.6 Make candidate preparation validate and ready the exact trusted release PR. +- [ ] 2.7 Remove phantom Homebrew Core state when optional submission is disabled. +- [ ] 2.7 Track Core submission, acceptance, and public verification when enabled. + +### Checkpoint A β€” local repository gate + +- [ ] Focused regressions pass. +- [ ] `npm run typecheck` passes. +- [ ] `npm run build` passes. +- [ ] `npm run test:release` passes. +- [ ] Full coverage suite passes. +- [ ] Lint ratchet and `git diff --check` pass. +- [ ] Production dependency audit passes. +- [ ] npm pack and package-manager smokes pass. + +### Phase 3 β€” GitHub bootstrap and controls + +- [ ] 3.1 Reauthenticate GitHub CLI as `ntanwir10`. +- [ ] 3.1 Create inert workflow-bootstrap branch/PR from `origin/main`. +- [ ] 3.1 Confirm release workflows are registered on `main` with automation off. +- [ ] 3.2 Install/configure `guardscan-release-bot` on GuardScan and shared catalog. +- [ ] 3.2 Protect release tags and restrict ledger writes to the release identity. +- [ ] 3.2 Configure and verify cross-repository catalog notifications. +- [ ] 3.3 Merge bootstrap truth back into `release/1.1.0` and rerun exact-SHA CI. +- [ ] 3.3 Mark PR #32 ready and complete real review. + +### Phase 4 β€” provider onboarding + +- [ ] 4.1 Configure and rehearse npm trusted publishing; remove `NPM_TOKEN` after success. +- [ ] 4.1 Configure and rehearse TestPyPI/PyPI pending trusted publishers. +- [ ] 4.2 Configure Apple Developer ID and notarization credentials. +- [ ] 4.2 Configure Azure Artifact Signing and GitHub OIDC federation. +- [ ] 4.3 Complete Microsoft CLA and configure scoped WinGet token. +- [ ] 4.3 Confirm Chocolatey ownership and configure API key. +- [ ] 4.4 Configure optional Homebrew Core submission authority, or leave it disabled. +- [ ] 4.5 Add credential/certificate/App health and expiry monitoring. + +### Checkpoint B β€” provider rehearsal + +- [ ] Fork PRs cannot access secrets or mint publication OIDC tokens. +- [ ] Five native targets are signed and verified on native runners. +- [ ] Five TestPyPI wheels pass pip and pipx lifecycle tests. +- [ ] Registry preflights distinguish absent, identical, and conflicting state. +- [ ] `RELEASE_AUTOMATION_ENABLED` remains false until all required checks pass. + +### Phase 5 β€” RC and stable execution + +- [ ] 5.1 Enable automation and dispatch `v1.1.0-rc.1` from `main` against PR #32. +- [ ] 5.1 Verify npm `next`, GitHub prerelease, PyPI `1.1.0rc1`, and catalog previews. +- [ ] 5.2 Collect 24 hourly samples for every required channel. +- [ ] 5.2 Verify unchanged source head, signatures, digests, audit, and incident state. +- [ ] 5.3 Materialize promotion decision and auto-merge the unchanged stable PR. +- [ ] 5.3 Publish and verify stable GitHub, npm, PyPI, Homebrew tap, and Scoop. +- [ ] 5.3 Submit WinGet and Chocolatey stable packages. +- [ ] 5.4 Poll moderated channels until public and installable. +- [ ] 5.4 Submit/poll optional Homebrew Core without blocking stable completion. +- [ ] 5.5 After stable verification, run seven-day Cloudflare 410 retirement and delete it. + +### Final acceptance + +- [ ] Every required public command reports `1.1.0`. +- [ ] Offline scan, SPDX, CycloneDX, telemetry-disabled status, install, upgrade, and uninstall pass. +- [ ] Public provenance, signatures, checksums, SBOMs, manifest, and immutable assets agree. +- [ ] Required ledger channels are `verified`; moderated/optional states remain truthful. +- [ ] Rollback and forward-fix paths are rehearsed and append-only. +- [ ] User-facing docs advertise only channels verified in public registries. + ## Implementation progress β€” local automation complete, channel acceptance pending - [x] Implement the exact-source RC and stable train with 30-minute reconciliation and a 24-hour machine promotion policy. From 773a824433ca59bc0fe790164f2d849d0e347a20 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 9 Aug 2026 17:38:24 -0400 Subject: [PATCH 21/23] harden GuardScan 1.1.0 release train --- .github/workflows/release-build.yml | 2 +- .github/workflows/release-canary.yml | 17 +- .../workflows/release-first-withdrawal.yml | 424 ++++++++ .github/workflows/release-publish.yml | 191 +++- .github/workflows/release-train.yml | 919 ++++++++++++++++-- .../contracts/release-contracts.test.ts | 152 +++ .../integration/ai-providers-enhanced.test.ts | 157 ++- cli/__tests__/scripts/release-train.test.ts | 683 ++++++++++++- .../scripts/release-workflows.test.ts | 93 +- cli/package-lock.json | 42 +- cli/package.json | 4 +- ...uardscan.promotion-decision.v1.schema.json | 21 +- .../guardscan.release-event.v1.schema.json | 194 ++++ .../guardscan.release-state.v2.schema.json | 124 ++- cli/scripts/eslint-baseline.json | 4 - cli/scripts/release/candidate.js | 18 +- cli/scripts/release/events.js | 375 +++++++ .../release/first-release-withdrawal.js | 190 ++++ cli/scripts/release/index.js | 121 ++- cli/scripts/release/promotion.js | 5 + cli/scripts/release/publication-evidence.js | 76 ++ cli/scripts/release/reconcile.js | 86 +- cli/src/core/cost-guard.ts | 9 - docs/FUNCTIONAL_ACCEPTANCE.md | 2 +- docs/RELEASE_AUTOMATION.md | 52 +- docs/RELEASE_ONBOARDING.md | 21 +- 26 files changed, 3766 insertions(+), 216 deletions(-) create mode 100644 .github/workflows/release-first-withdrawal.yml create mode 100644 cli/scripts/release/first-release-withdrawal.js create mode 100644 cli/scripts/release/publication-evidence.js diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 144368e..f57e62a 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -44,7 +44,7 @@ jobs: npm audit --omit=dev --audit-level=high npm run test:release npm run test:package - npm run test:package-manager + npm run test:package-manager -- --manager npm - run: git diff --check npm: diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 6556120..ec829d2 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -26,6 +26,16 @@ jobs: outputs: trains: ${{ steps.matrix.outputs.trains }} steps: + - name: Validate optional manual version before minting credentials + env: + REQUEST_VERSION: ${{ inputs.version }} + run: | + node - <<'NODE' + const version = process.env.REQUEST_VERSION; + if (version && !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-rc\.(0|[1-9][0-9]*))?$/.test(version)) { + throw new Error('manual canary version is invalid'); + } + NODE - name: Create short-lived release app token id: app if: inputs.version == '' || vars.RELEASE_AUTOMATION_ENABLED == 'true' @@ -37,11 +47,12 @@ jobs: id: matrix env: GH_TOKEN: ${{ steps.app.outputs.token }} + REQUEST_VERSION: ${{ inputs.version }} run: | - if [ -n "${{ inputs.version }}" ]; then + if [ -n "$REQUEST_VERSION" ]; then printf '{"trains":[{"version":"%s","releasePr":0,"channel":"%s"}]}\n' \ - "${{ inputs.version }}" \ - "$([[ '${{ inputs.version }}' == *-rc.* ]] && echo rc || echo stable)" \ + "$REQUEST_VERSION" \ + "$([[ "$REQUEST_VERSION" == *-rc.* ]] && echo rc || echo stable)" \ > active.json else gh api \ diff --git a/.github/workflows/release-first-withdrawal.yml b/.github/workflows/release-first-withdrawal.yml new file mode 100644 index 0000000..b171c79 --- /dev/null +++ b/.github/workflows/release-first-withdrawal.yml @@ -0,0 +1,424 @@ +name: Withdraw first stable release + +on: + workflow_call: + inputs: + version: + description: Defective first stable version + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-ledger + cancel-in-progress: false + +env: + RELEASE_NODE_VERSION: 22.23.1 + DEFECTIVE_VERSION: ${{ inputs.version }} + +jobs: + withdraw: + name: Withdraw without inventing a known-good baseline + if: vars.RELEASE_AUTOMATION_ENABLED == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Validate untrusted request before minting credentials + run: | + node - <<'NODE' + const stable = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + if (!stable.test(process.env.DEFECTIVE_VERSION || '')) { + throw new Error('first-release withdrawal version is invalid'); + } + NODE + - name: Create short-lived cross-repository release app token + id: app + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: v${{ inputs.version }} + fetch-depth: 0 + token: ${{ steps.app.outputs.token }} + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.RELEASE_NODE_VERSION }} + - working-directory: cli + run: npm ci + - name: Verify protected first-release authority + id: authority + run: | + git fetch origin release-ledger + git worktree add ledger-branch origin/release-ledger + LEDGER_COMMIT="$(git -C ledger-branch rev-parse HEAD)" + export LEDGER_COMMIT + node - <<'NODE' + const fs = require('fs'); + const {assertFirstReleaseWithdrawal} = require('./cli/scripts/release/first-release-withdrawal'); + const authority = assertFirstReleaseWithdrawal( + 'ledger-branch', + process.env.DEFECTIVE_VERSION, + process.env.LEDGER_COMMIT + ); + fs.writeFileSync( + 'first-release-authority.json', + `${JSON.stringify(authority, null, 2)}\n` + ); + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `completed=${String(authority.alreadyCompleted)}\n` + ); + NODE + DEFECTIVE_TAG="v$DEFECTIVE_VERSION" + cp "ledger-branch/events/$DEFECTIVE_TAG.jsonl" release-events.jsonl + if [ "$(node -p 'String(require("./first-release-authority.json").alreadyCompleted)')" = true ]; then + cp "ledger-branch/recoveries/$DEFECTIVE_TAG-plan.json" rollback-plan.json + cp "ledger-branch/recoveries/$DEFECTIVE_TAG-evidence.json" rollback-evidence.json + fi + - name: Generate and persist the first-release withdrawal start + if: steps.authority.outputs.completed != 'true' + env: + RELEASE_APP_TOKEN: ${{ steps.app.outputs.token }} + run: | + DEFECTIVE_TAG="v$DEFECTIVE_VERSION" + ROLLBACK_KEY="rollback:$DEFECTIVE_VERSION" + export ROLLBACK_KEY + ROLLBACK_TIMESTAMP="$(node - <<'NODE' + const {readEvents} = require('./cli/scripts/release/events'); + const existing = readEvents('release-events.jsonl') + .find(event => event.idempotencyKey === process.env.ROLLBACK_KEY); + process.stdout.write(existing?.timestamp || new Date().toISOString()); + NODE + )" + node cli/scripts/release/index.js rollback \ + --ledger release-events.jsonl \ + --timestamp "$ROLLBACK_TIMESTAMP" \ + --idempotency-key "$ROLLBACK_KEY" \ + --tag "$DEFECTIVE_TAG" \ + --first-release-withdrawal \ + --first-release-authority first-release-authority.json \ + > rollback-plan.json + node - <<'NODE' + const plan = require('./rollback-plan.json'); + if (plan.schemaVersion !== 'guardscan.rollback-plan.v1' + || plan.mode !== 'first-release-withdrawal' + || plan.version !== process.env.DEFECTIVE_VERSION + || plan.knownGood !== undefined + || plan.forwardFixBranch !== undefined) { + throw new Error('generated first-release withdrawal plan is invalid'); + } + const {readEvents} = require('./cli/scripts/release/events'); + const events = readEvents('release-events.jsonl'); + if (!events.some(event => ( + event.type === 'incident_opened' + && event.payload.kind === 'recovery' + ))) { + throw new Error('first-release withdrawal must retain an open recovery incident'); + } + for (const action of plan.actions.filter(item => item.automation === 'external-action-required')) { + if (!events.some(event => ( + event.type === 'action_required' && event.channel === action.channel + ))) { + throw new Error(`withdrawal ledger is missing external authority action for ${action.channel}`); + } + } + NODE + mkdir -p ledger-branch/events + cp release-events.jsonl "ledger-branch/events/$DEFECTIVE_TAG.jsonl" + ( + cd ledger-branch + git config user.name guardscan-release-bot + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add "events/$DEFECTIVE_TAG.jsonl" + git commit -m "first release withdrawal started: $DEFECTIVE_TAG" || exit 0 + git push origin HEAD:release-ledger + ) + - name: Remove or verify the exact first-release catalog projection + id: catalog + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + ALREADY_COMPLETED: ${{ steps.authority.outputs.completed }} + run: | + CATALOG_BRANCH="rollback/v$DEFECTIVE_VERSION-remove-first-release" + PUBLISH_BRANCH="guardscan/v$DEFECTIVE_VERSION" + gh repo clone ntanwir10/homebrew-tap catalog-withdrawal + git -C catalog-withdrawal fetch origin main + CATALOG_BASE="$(git -C catalog-withdrawal rev-parse origin/main)" + PUBLISH_PRS="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$PUBLISH_BRANCH" --base main --state open --limit 20 \ + --json number,headRefName,baseRefName,headRepository)" + export PUBLISH_PRS PUBLISH_BRANCH + node - <<'NODE' + const prs = JSON.parse(process.env.PUBLISH_PRS || '[]'); + if (prs.length > 1 || prs.some(pr => ( + pr.headRefName !== process.env.PUBLISH_BRANCH + || pr.baseRefName !== 'main' + || pr.headRepository?.nameWithOwner?.toLowerCase() !== 'ntanwir10/homebrew-tap' + ))) { + throw new Error('catalog publication pull request identity is ambiguous'); + } + NODE + PUBLISH_PR="$(node -p 'String(JSON.parse(process.env.PUBLISH_PRS || "[]")[0]?.number || "")')" + if [ -n "$PUBLISH_PR" ]; then + gh pr close --repo ntanwir10/homebrew-tap "$PUBLISH_PR" \ + --comment "Closed by the protected first-release withdrawal for v$DEFECTIVE_VERSION." + fi + test "$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$PUBLISH_BRANCH" --base main --state open --limit 1 \ + --json number --jq length)" = 0 + if [ "$ALREADY_COMPLETED" = true ]; then + for CATALOG_PATH in Formula/guardscan.rb bucket/guardscan.json channel-lock.json; do + if git -C catalog-withdrawal cat-file -e "$CATALOG_BASE:$CATALOG_PATH" 2>/dev/null; then + echo "completed withdrawal was republished at $CATALOG_PATH" >&2 + exit 1 + fi + done + CATALOG_STATE="$(node -p 'require("./rollback-evidence.json").catalog.state')" + CATALOG_BRANCH="$(node -p 'require("./rollback-evidence.json").catalog.branch')" + CATALOG_PR="$(node -p 'String(require("./rollback-evidence.json").catalog.pullRequest)')" + CATALOG_MERGE_SHA="$(node -p 'require("./rollback-evidence.json").catalog.commit')" + git -C catalog-withdrawal fetch origin "$CATALOG_MERGE_SHA" + for CATALOG_PATH in Formula/guardscan.rb bucket/guardscan.json channel-lock.json; do + if git -C catalog-withdrawal cat-file -e "$CATALOG_MERGE_SHA:$CATALOG_PATH" 2>/dev/null; then + echo "stored withdrawal evidence contains $CATALOG_PATH" >&2 + exit 1 + fi + done + echo "state=$CATALOG_STATE" >> "$GITHUB_OUTPUT" + echo "branch=$CATALOG_BRANCH" >> "$GITHUB_OUTPUT" + echo "pull_request=$CATALOG_PR" >> "$GITHUB_OUTPUT" + echo "commit=$CATALOG_MERGE_SHA" >> "$GITHUB_OUTPUT" + exit 0 + fi + CATALOG_REMOTE_HEAD="" + if git -C catalog-withdrawal fetch origin \ + "refs/heads/$CATALOG_BRANCH:refs/remotes/origin/$CATALOG_BRANCH"; then + CATALOG_REMOTE_HEAD="$(git -C catalog-withdrawal rev-parse "refs/remotes/origin/$CATALOG_BRANCH")" + fi + git -C catalog-withdrawal switch -c "$CATALOG_BRANCH" "$CATALOG_BASE" + export DEFECTIVE_VERSION + node - <<'NODE' + const fs = require('fs'); + const {prepareFirstReleaseCatalogWithdrawal} = require('./cli/scripts/release/first-release-withdrawal'); + const result = prepareFirstReleaseCatalogWithdrawal( + 'catalog-withdrawal', + process.env.DEFECTIVE_VERSION, + require('./first-release-authority.json').defectiveCommit + ); + fs.writeFileSync('catalog-withdrawal.json', `${JSON.stringify(result, null, 2)}\n`); + NODE + git -C catalog-withdrawal add -A -- \ + Formula/guardscan.rb bucket/guardscan.json channel-lock.json + CATALOG_CHANGED_FILES="$(git -C catalog-withdrawal diff --cached --name-status)" + if [ -z "$CATALOG_CHANGED_FILES" ]; then + test "$(node -p 'require("./catalog-withdrawal.json").state')" = already-absent + CATALOG_PR=0 + CATALOG_MERGE_SHA="$CATALOG_BASE" + CATALOG_STATE=already-absent + else + export CATALOG_CHANGED_FILES + node - <<'NODE' + const rows = process.env.CATALOG_CHANGED_FILES.split('\n').filter(Boolean).sort(); + const expected = [ + 'D\tFormula/guardscan.rb', + 'D\tbucket/guardscan.json', + 'D\tchannel-lock.json', + ]; + if (JSON.stringify(rows) !== JSON.stringify(expected)) { + throw new Error(`catalog withdrawal contains unreviewed changes: ${rows.join(', ')}`); + } + NODE + git -C catalog-withdrawal config user.name guardscan-release-bot + git -C catalog-withdrawal config user.email 41898282+github-actions[bot]@users.noreply.github.com + git -C catalog-withdrawal commit \ + -m "withdraw GuardScan v$DEFECTIVE_VERSION first-release catalog" + CATALOG_HEAD="$(git -C catalog-withdrawal rev-parse HEAD)" + if [ -n "$CATALOG_REMOTE_HEAD" ]; then + git -C catalog-withdrawal push \ + --force-with-lease="refs/heads/$CATALOG_BRANCH:$CATALOG_REMOTE_HEAD" \ + origin "HEAD:refs/heads/$CATALOG_BRANCH" + else + git -C catalog-withdrawal push origin "HEAD:refs/heads/$CATALOG_BRANCH" + fi + CATALOG_PRS="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$CATALOG_BRANCH" --base main --state all --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository,mergeCommit)" + export CATALOG_HEAD CATALOG_PRS + CATALOG_PR="$(node - <<'NODE' + const prs = JSON.parse(process.env.CATALOG_PRS || '[]'); + const matches = prs.filter(pr => pr.baseRefName === 'main'); + if (matches.some(pr => pr.state === 'CLOSED' && !pr.mergedAt)) { + throw new Error('catalog withdrawal branch has a closed-unmerged pull request'); + } + const bound = matches.find(pr => ( + ['OPEN', 'MERGED'].includes(pr.state) + && pr.headRefOid === process.env.CATALOG_HEAD + && pr.headRepository?.nameWithOwner?.toLowerCase() === 'ntanwir10/homebrew-tap' + )); + process.stdout.write(String(bound?.number || '')); + NODE + )" + if [ -z "$CATALOG_PR" ]; then + gh pr create \ + --repo ntanwir10/homebrew-tap \ + --base main \ + --head "$CATALOG_BRANCH" \ + --title "Withdraw GuardScan v$DEFECTIVE_VERSION first-release catalog" \ + --body "Removes only the generated first-party listings for defective v$DEFECTIVE_VERSION. The immutable release remains auditable." + CATALOG_PR="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$CATALOG_BRANCH" --base main --state open --limit 1 --json number --jq '.[0].number')" + fi + CATALOG_PR_STATE="$(gh pr view --repo ntanwir10/homebrew-tap "$CATALOG_PR" --json state --jq .state)" + if [ "$CATALOG_PR_STATE" = OPEN ]; then + gh pr checks --repo ntanwir10/homebrew-tap "$CATALOG_PR" --watch --fail-fast + gh pr merge --repo ntanwir10/homebrew-tap "$CATALOG_PR" \ + --squash --match-head-commit "$CATALOG_HEAD" + fi + CATALOG_PR_STATE="$(gh pr view --repo ntanwir10/homebrew-tap "$CATALOG_PR" --json state --jq .state)" + test "$CATALOG_PR_STATE" = MERGED + CATALOG_MERGE_SHA="$(gh pr view --repo ntanwir10/homebrew-tap "$CATALOG_PR" \ + --json mergeCommit --jq .mergeCommit.oid)" + CATALOG_STATE=removed + fi + case "$CATALOG_MERGE_SHA" in + ''|*[!a-f0-9]*) echo "catalog withdrawal has no trusted commit" >&2; exit 1 ;; + esac + test "${#CATALOG_MERGE_SHA}" = 40 + git -C catalog-withdrawal fetch origin "$CATALOG_MERGE_SHA" + for CATALOG_PATH in Formula/guardscan.rb bucket/guardscan.json channel-lock.json; do + if git -C catalog-withdrawal cat-file -e "$CATALOG_MERGE_SHA:$CATALOG_PATH" 2>/dev/null; then + echo "catalog withdrawal left $CATALOG_PATH published" >&2 + exit 1 + fi + done + echo "state=$CATALOG_STATE" >> "$GITHUB_OUTPUT" + echo "branch=$CATALOG_BRANCH" >> "$GITHUB_OUTPUT" + echo "pull_request=$CATALOG_PR" >> "$GITHUB_OUTPUT" + echo "commit=$CATALOG_MERGE_SHA" >> "$GITHUB_OUTPUT" + - name: Complete repository withdrawal and deactivate the train + if: steps.authority.outputs.completed != 'true' + env: + CATALOG_STATE: ${{ steps.catalog.outputs.state }} + CATALOG_BRANCH: ${{ steps.catalog.outputs.branch }} + CATALOG_PR: ${{ steps.catalog.outputs.pull_request }} + CATALOG_COMMIT: ${{ steps.catalog.outputs.commit }} + run: | + node - <<'NODE' + const fs = require('fs'); + const {appendEvent, readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + const plan = require('./rollback-plan.json'); + const events = readEvents('release-events.jsonl'); + const state = materializeReleaseState(events); + if (state.recovery?.mode !== 'first-release-withdrawal' + || !Object.values(state.incidents).some(incident => ( + incident.kind === 'recovery' && incident.status === 'open' + ))) { + throw new Error('first-release withdrawal lost its protected recovery state'); + } + const now = new Date().toISOString(); + const externalActionsPending = plan.actions + .filter(action => action.automation === 'external-action-required') + .map(action => action.channel) + .sort(); + const evidence = { + schemaVersion: 'guardscan.rollback-repository-evidence.v1', + mode: 'first-release-withdrawal', + defectiveVersion: process.env.DEFECTIVE_VERSION, + requiredNextVersion: state.recovery.requiredNextVersion, + externalActionsPending, + catalog: { + state: process.env.CATALOG_STATE, + branch: process.env.CATALOG_BRANCH, + pullRequest: Number(process.env.CATALOG_PR), + commit: process.env.CATALOG_COMMIT, + }, + completedAt: now, + }; + fs.writeFileSync('rollback-evidence.json', `${JSON.stringify(evidence, null, 2)}\n`); + const terminal = { + github: 'superseded', + pnpm: 'superseded', + yarn: 'superseded', + bun: 'superseded', + homebrew: 'withdrawn', + scoop: 'withdrawn', + }; + for (const [channel, channelState] of Object.entries(state.channels)) { + const type = channelState.status === 'planned' + || (channel === 'github' && !channelState.publication) + ? 'withdrawn' + : terminal[channel]; + if (!type) continue; + appendEvent('release-events.jsonl', { + version: state.version, + tag: state.tag, + commit: state.commit, + timestamp: now, + type, + channel, + idempotencyKey: `first-release-withdrawal:${channel}:${state.version}`, + payload: { + artifactIds: channelState.artifactIds, + ...(channelState.remoteIdentity ? {remoteIdentity: channelState.remoteIdentity} : {}), + ...(channelState.remoteDigest ? {remoteDigest: channelState.remoteDigest} : {}), + }, + }); + } + appendEvent('release-events.jsonl', { + version: state.version, + tag: state.tag, + commit: state.commit, + timestamp: now, + type: 'rollback_repository_completed', + idempotencyKey: `rollback-repository-completed:${state.version}`, + payload: evidence, + }); + const completed = materializeReleaseState(readEvents('release-events.jsonl')); + for (const [channel, channelState] of Object.entries(completed.channels)) { + if (!externalActionsPending.includes(channel) + && !['withdrawn', 'superseded'].includes(channelState.status)) { + throw new Error(`repository withdrawal left ${channel} non-terminal`); + } + } + const expectedRecoveryStatus = externalActionsPending.length > 0 + ? 'provider-actions-pending' + : 'repository-completed'; + if (completed.recovery?.status !== expectedRecoveryStatus) { + throw new Error('repository withdrawal completion state is inconsistent'); + } + NODE + DEFECTIVE_TAG="v$DEFECTIVE_VERSION" + cp release-events.jsonl "ledger-branch/events/$DEFECTIVE_TAG.jsonl" + mkdir -p ledger-branch/recoveries + cp rollback-plan.json "ledger-branch/recoveries/$DEFECTIVE_TAG-plan.json" + cp rollback-evidence.json "ledger-branch/recoveries/$DEFECTIVE_TAG-evidence.json" + node - <<'NODE' + const fs = require('fs'); + const file = 'ledger-branch/active-versions.json'; + const active = JSON.parse(fs.readFileSync(file, 'utf8')); + active.trains = active.trains.filter( + train => train.version !== process.env.DEFECTIVE_VERSION + ); + fs.writeFileSync(file, `${JSON.stringify(active, null, 2)}\n`); + NODE + ( + cd ledger-branch + git add events active-versions.json recoveries + git commit -m "first release repository withdrawal complete: v$DEFECTIVE_VERSION" || exit 0 + git push origin HEAD:release-ledger + ) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rollback-plan-v${{ inputs.version }} + path: | + first-release-authority.json + rollback-plan.json + rollback-evidence.json + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 30cf390..b311858 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -31,6 +31,10 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: release-payload-${{ inputs.tag }} @@ -88,6 +92,29 @@ jobs: --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-train.yml@" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ redownload/SHA256SUMS + - name: Bind the exact GitHub release asset set to provider evidence + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + node - <<'NODE' + const crypto = require('crypto'); + const fs = require('fs'); + const path = require('path'); + const {writePublicationEvidence} = require('./cli/scripts/release/publication-evidence'); + const files = {}; + for (const entry of fs.readdirSync('redownload', {withFileTypes: true})) { + if (!entry.isFile()) throw new Error('GitHub release evidence contains a non-file'); + files[entry.name] = crypto.createHash('sha256') + .update(fs.readFileSync(path.join('redownload', entry.name))).digest('hex'); + } + writePublicationEvidence('github-publication-evidence.json', { + channel: 'github', + version: process.env.RELEASE_TAG.slice(1), + tag: process.env.RELEASE_TAG, + remoteIdentity: `github:${process.env.GITHUB_REPOSITORY}/releases/tag/${process.env.RELEASE_TAG}`, + files, + }); + NODE - name: Publish verified draft env: GH_TOKEN: ${{ github.token }} @@ -95,6 +122,12 @@ jobs: if [ "$(gh release view "${{ inputs.tag }}" --repo "${{ github.repository }}" --json isDraft --jq .isDraft)" = true ]; then gh release edit "${{ inputs.tag }}" --repo "${{ github.repository }}" --draft=false fi + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-github-evidence-${{ inputs.tag }} + path: github-publication-evidence.json + if-no-files-found: error + retention-days: 30 npm: name: npm trusted publication @@ -139,6 +172,28 @@ jobs: - name: Verify public registry integrity working-directory: cli run: npm run release:npm-preflight -- --artifact-dir ../npm-artifact + - name: Bind the exact npm tarball to provider evidence + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + node - <<'NODE' + const fs = require('fs'); + const {writePublicationEvidence} = require('./cli/scripts/release/publication-evidence'); + const artifact = JSON.parse(fs.readFileSync('npm-artifact/npm-artifact.json')); + writePublicationEvidence('npm-publication-evidence.json', { + channel: 'npm', + version: process.env.RELEASE_TAG.slice(1), + tag: process.env.RELEASE_TAG, + remoteIdentity: `npm:${artifact.packageName}@${artifact.version}`, + files: {[artifact.filename]: artifact.sha256}, + }); + NODE + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-npm-evidence-${{ inputs.tag }} + path: npm-publication-evidence.json + if-no-files-found: error + retention-days: 30 pypi-test: name: TestPyPI trusted publication @@ -150,6 +205,10 @@ jobs: contents: read id-token: write steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: release-payload-${{ inputs.tag }} @@ -394,6 +453,36 @@ jobs: raise SystemExit("PyPI did not converge to the complete tested wheel set") time.sleep(10) PY + - name: Bind the exact PyPI wheel set to provider evidence + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + node - <<'NODE' + const crypto = require('crypto'); + const fs = require('fs'); + const path = require('path'); + const {writePublicationEvidence} = require('./cli/scripts/release/publication-evidence'); + const files = {}; + for (const entry of fs.readdirSync('dist', {withFileTypes: true})) { + if (!entry.isFile() || !entry.name.endsWith('.whl')) continue; + files[entry.name] = crypto.createHash('sha256') + .update(fs.readFileSync(path.join('dist', entry.name))).digest('hex'); + } + const version = process.env.RELEASE_TAG.slice(1); + writePublicationEvidence('pypi-publication-evidence.json', { + channel: 'pypi', + version, + tag: process.env.RELEASE_TAG, + remoteIdentity: `pypi:guardscan-cli@${version.replace('-rc.', 'rc')}`, + files, + }); + NODE + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-pypi-evidence-${{ inputs.tag }} + path: pypi-publication-evidence.json + if-no-files-found: error + retention-days: 30 catalog: name: Shared Homebrew and Scoop catalog projection @@ -645,8 +734,8 @@ jobs: [IO.File]::WriteAllText('winget-digest-input.txt', $digestInput, [Text.UTF8Encoding]::new($false)) $manifestDigest = (Get-FileHash winget-digest-input.txt -Algorithm SHA256).Hash.ToLowerInvariant() - function Write-WinGetEvidence($state, $remoteIdentity, $pullRequest, $commit, $publicBytesVerified) { - [ordered]@{ + function Write-WinGetEvidence($state, $remoteIdentity, $pullRequest, $commit, $publicBytesVerified, $reason = $null) { + $evidence = [ordered]@{ schemaVersion = 'guardscan.moderated-submission.v1' channel = 'winget' version = $version @@ -665,7 +754,9 @@ jobs: pendingStateQuery = 'digest-bound open pull request, then protected release ledger' } checkedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') - } | ConvertTo-Json -Depth 8 | Out-File winget-evidence.json -Encoding utf8 + } + if (-not [string]::IsNullOrWhiteSpace($reason)) { $evidence.reason = $reason } + $evidence | ConvertTo-Json -Depth 8 | Out-File winget-evidence.json -Encoding utf8 } function Assert-WinGetRemoteFiles($remoteFiles) { @@ -726,18 +817,6 @@ jobs: throw 'Unable to query public WinGet catalog reliably' } - $submitter = (gh api user --jq .login).Trim() - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($submitter)) { - throw 'Unable to resolve WinGet submitting identity' - } - $pullTitle = "GuardScan $version [$($manifestDigest.Substring(0, 12))]" - $pullRequest = Find-WinGetPullRequest $submitter $pullTitle - if ($null -ne $pullRequest) { - Assert-WinGetPullRequest $pullRequest - Write-WinGetEvidence 'pending' "github:microsoft/winget-pkgs/pull/$($pullRequest.number)@$($pullRequest.head.sha)#$manifestPath" $($pullRequest.number) $($pullRequest.head.sha) $false - exit 0 - } - git fetch origin release-ledger if ($LASTEXITCODE -ne 0) { throw 'Unable to fetch protected release ledger' } $ledgerPath = "events/$($env:RELEASE_TAG).jsonl" @@ -747,8 +826,11 @@ jobs: $ledgerLines = git show "origin/release-ledger:$ledgerPath" if ($LASTEXITCODE -ne 0) { throw 'Unable to read protected release ledger' } $existing = $ledgerLines | ForEach-Object { $_ | ConvertFrom-Json } | - Where-Object { $_.type -eq 'channel_submitted' -and $_.channel -eq 'winget' } | - Select-Object -Last 1 + Where-Object { + $_.channel -eq 'winget' -and $_.type -in @( + 'channel_submitted', 'channel_accepted', 'channel_rejected', 'channel_corrected', 'channel_resubmitted' + ) + } | Select-Object -Last 1 if ($null -ne $existing) { if ($existing.payload.remoteDigest -ne $manifestDigest) { throw 'WinGet protected-ledger digest conflicts with rendered manifests' @@ -756,12 +838,47 @@ jobs: if ($null -eq $existing.payload.submission) { throw 'WinGet protected-ledger evidence is incomplete' } - $existing.payload.submission | ConvertTo-Json -Depth 8 | Out-File winget-evidence.json -Encoding utf8 - Write-Host 'WinGet pending provider state is not stronger than recorded evidence; the protected release ledger prevents a blind duplicate.' + $recordedPull = $existing.payload.submission.provider.pullRequest + if ($null -eq $recordedPull -or $recordedPull -le 0) { + throw 'WinGet protected-ledger pull request identity is invalid' + } + $pullJson = gh api "repos/microsoft/winget-pkgs/pulls/$recordedPull" + if ($LASTEXITCODE -ne 0) { throw 'Unable to query recorded WinGet pull request' } + $pullRequest = ($pullJson | Out-String | ConvertFrom-Json) + $remoteIdentity = "github:microsoft/winget-pkgs/pull/$($pullRequest.number)@$($pullRequest.head.sha)#$manifestPath" + if ($pullRequest.merged) { + Assert-WinGetPullRequest $pullRequest + Write-WinGetEvidence 'accepted' $remoteIdentity $($pullRequest.number) $($pullRequest.head.sha) $false + exit 0 + } + if ($pullRequest.state -eq 'closed') { + $labels = @($pullRequest.labels | ForEach-Object { $_.name } | Sort-Object) -join ',' + $reason = "provider pull request closed without merge; labels=$labels" + Write-WinGetEvidence 'rejected' $remoteIdentity $($pullRequest.number) $($pullRequest.head.sha) $false $reason + exit 0 + } + Assert-WinGetPullRequest $pullRequest + $state = if ($existing.type -in @('channel_rejected', 'channel_corrected')) { + 'resubmitted' + } else { 'pending-ledger' } + Write-WinGetEvidence $state $remoteIdentity $($pullRequest.number) $($pullRequest.head.sha) $false + Write-Host 'WinGet provider state was resolved from the recorded pull request; the protected release ledger prevents a blind duplicate.' exit 0 } } + $submitter = (gh api user --jq .login).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($submitter)) { + throw 'Unable to resolve WinGet submitting identity' + } + $pullTitle = "GuardScan $version [$($manifestDigest.Substring(0, 12))]" + $pullRequest = Find-WinGetPullRequest $submitter $pullTitle + if ($null -ne $pullRequest) { + Assert-WinGetPullRequest $pullRequest + Write-WinGetEvidence 'pending' "github:microsoft/winget-pkgs/pull/$($pullRequest.number)@$($pullRequest.head.sha)#$manifestPath" $($pullRequest.number) $($pullRequest.head.sha) $false + exit 0 + } + $wingetCreateUrl = "https://github.com/microsoft/winget-create/releases/download/v$env:RELEASE_WINGETCREATE_VERSION/wingetcreate.exe" Invoke-WebRequest $wingetCreateUrl -OutFile wingetcreate.exe $wingetCreateDigest = (Get-FileHash wingetcreate.exe -Algorithm SHA256).Hash.ToLowerInvariant() @@ -839,8 +956,8 @@ jobs: $packageIdentity = "guardscan@$version" $publicUrl = "https://community.chocolatey.org/api/v2/package/guardscan/$version" - function Write-ChocolateyEvidence($state, $remoteIdentity, $publicBytesVerified) { - [ordered]@{ + function Write-ChocolateyEvidence($state, $remoteIdentity, $publicBytesVerified, $reason = $null) { + $evidence = [ordered]@{ schemaVersion = 'guardscan.moderated-submission.v1' channel = 'chocolatey' version = $version @@ -856,7 +973,9 @@ jobs: pendingStateQuery = 'public package bytes only; protected release ledger prevents a blind duplicate after recorded submission' } checkedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') - } | ConvertTo-Json -Depth 8 | Out-File chocolatey-evidence.json -Encoding utf8 + } + if (-not [string]::IsNullOrWhiteSpace($reason)) { $evidence.reason = $reason } + $evidence | ConvertTo-Json -Depth 8 | Out-File chocolatey-evidence.json -Encoding utf8 } $public = $false @@ -887,16 +1006,38 @@ jobs: $ledgerLines = git show "origin/release-ledger:$ledgerPath" if ($LASTEXITCODE -ne 0) { throw 'Unable to read protected release ledger' } $existing = $ledgerLines | ForEach-Object { $_ | ConvertFrom-Json } | - Where-Object { $_.type -eq 'channel_submitted' -and $_.channel -eq 'chocolatey' } | + Where-Object { + $_.channel -eq 'chocolatey' -and $_.type -in @( + 'channel_submitted', 'channel_accepted', 'channel_rejected', 'channel_corrected', 'channel_resubmitted' + ) + } | Select-Object -Last 1 if ($null -ne $existing) { if ($existing.payload.remoteDigest -ne $packageDigest) { throw 'Chocolatey protected-ledger digest conflicts with rendered package' } - if ($null -eq $existing.payload.submission) { + $submission = if ($null -ne $existing.payload.submission) { + $existing.payload.submission + } else { + ($ledgerLines | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.channel -eq 'chocolatey' -and $null -ne $_.payload.submission } | + Select-Object -Last 1).payload.submission + } + if ($null -eq $submission) { throw 'Chocolatey protected-ledger evidence is incomplete' } - $existing.payload.submission | ConvertTo-Json -Depth 8 | Out-File chocolatey-evidence.json -Encoding utf8 + $submittedAt = [DateTimeOffset]::Parse($existing.timestamp) + if ($existing.type -eq 'channel_rejected') { + $reason = $existing.payload.reason ?? 'provider moderation requires maintainer correction' + Write-ChocolateyEvidence 'rejected' "chocolatey:$packageIdentity" $false $reason + exit 0 + } + if (([DateTimeOffset]::UtcNow - $submittedAt).TotalDays -ge 35) { + $reason = 'public package remained absent through the documented 35-day no-response moderation window; maintainer confirmation is required' + Write-ChocolateyEvidence 'rejected' "chocolatey:$packageIdentity" $false $reason + exit 0 + } + $submission | ConvertTo-Json -Depth 8 | Out-File chocolatey-evidence.json -Encoding utf8 Write-Host 'Chocolatey pending moderation cannot be queried reliably; the protected release ledger prevents a blind duplicate.' exit 0 } diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml index 990e229..65b9f17 100644 --- a/.github/workflows/release-train.yml +++ b/.github/workflows/release-train.yml @@ -145,6 +145,10 @@ jobs: && vars.RELEASE_AUTOMATION_ENABLED == 'true' && (inputs.action == 'candidate' || inputs.action == 'promote') runs-on: ubuntu-24.04 + env: + REQUEST_ACTION: ${{ inputs.action }} + REQUEST_VERSION: ${{ inputs.version }} + REQUEST_RELEASE_PR: ${{ inputs.release_pr }} concurrency: group: release-ledger cancel-in-progress: false @@ -155,7 +159,25 @@ jobs: channel: ${{ steps.result.outputs.channel }} release_pr: ${{ steps.result.outputs.release_pr }} source_pr_head: ${{ steps.result.outputs.source_pr_head }} + source_pr_base: ${{ steps.result.outputs.source_pr_base }} + source_pr_tree: ${{ steps.result.outputs.source_pr_tree }} steps: + - name: Validate untrusted release request syntax before minting credentials + run: | + node - <<'NODE' + const action = process.env.REQUEST_ACTION; + const version = process.env.REQUEST_VERSION; + const releasePr = process.env.REQUEST_RELEASE_PR; + if (!['candidate', 'promote'].includes(action)) { + throw new Error('prepare request action is invalid'); + } + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-rc\.(0|[1-9][0-9]*))?$/.test(version || '')) { + throw new Error('release request version is invalid'); + } + if (!/^[1-9][0-9]*$/.test(releasePr || '') || !Number.isSafeInteger(Number(releasePr))) { + throw new Error('release request PR is invalid'); + } + NODE - name: Create short-lived release app token id: app uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 @@ -167,14 +189,21 @@ jobs: env: GH_TOKEN: ${{ steps.app.outputs.token }} run: | - test -n "${{ inputs.release_pr }}" gh api \ -H "Accept: application/vnd.github.raw+json" \ "repos/${GITHUB_REPOSITORY}/contents/active-versions.json?ref=release-ledger" \ > active-versions.json node - <<'NODE' const active = require('./active-versions.json').trains || []; - const incompleteStable = active.filter(train => train.channel === 'stable'); + const stableVersion = (process.env.REQUEST_VERSION || '').replace(/-rc\.[0-9]+$/, ''); + const incompleteStable = active.filter(train => ( + train.channel === 'stable' + && !( + process.env.REQUEST_ACTION === 'promote' + && train.version === stableVersion + && train.releasePr === Number(process.env.REQUEST_RELEASE_PR) + ) + )); if (incompleteStable.length > 0) { throw new Error( `cannot start or promote while a stable train remains incomplete: ${ @@ -183,11 +212,15 @@ jobs: ); } NODE - gh api "repos/${GITHUB_REPOSITORY}/pulls/${{ inputs.release_pr }}" > release-pr.json + gh api "repos/${GITHUB_REPOSITORY}/pulls/$REQUEST_RELEASE_PR" > release-pr.json HEAD_SHA="$(node - <<'NODE' const pr = require('./release-pr.json'); - if (pr.state !== 'open') { - throw new Error('release PR must be OPEN'); + const resumableMergedPromotion = process.env.REQUEST_ACTION === 'promote' + && pr.state === 'closed' + && pr.merged === true + && /^[a-f0-9]{40}$/.test(pr.merge_commit_sha || ''); + if (pr.state !== 'open' && !resumableMergedPromotion) { + throw new Error('release PR must be OPEN or an already-merged resumable promotion'); } if (pr.base?.ref !== 'main') { throw new Error('release PR must target main'); @@ -204,18 +237,31 @@ jobs: process.stdout.write(pr.head.sha); NODE )" + BASE_SHA="$(node -p 'require("./release-pr.json").base.sha')" + TREE_SHA="$(gh api "repos/${GITHUB_REPOSITORY}/git/commits/$HEAD_SHA" --jq .tree.sha)" + for SHA in "$BASE_SHA" "$TREE_SHA"; do + case "$SHA" in + *[!a-f0-9]*|'') echo "Release PR commit identity is invalid" >&2; exit 1 ;; + esac + test "${#SHA}" = 40 + done echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "tree_sha=$TREE_SHA" >> "$GITHUB_OUTPUT" echo "is_draft=$(node -p 'String(require("./release-pr.json").draft)')" >> "$GITHUB_OUTPUT" + echo "pr_state=$(node -p 'require("./release-pr.json").merged ? "MERGED" : "OPEN"')" >> "$GITHUB_OUTPUT" + echo "merge_sha=$(node -p 'require("./release-pr.json").merge_commit_sha || ""')" >> "$GITHUB_OUTPUT" - name: Mark validated release PR ready and recheck candidate source if: inputs.action == 'candidate' env: GH_TOKEN: ${{ steps.app.outputs.token }} EXPECTED_HEAD: ${{ steps.source.outputs.head_sha }} + SOURCE_IS_DRAFT: ${{ steps.source.outputs.is_draft }} run: | - if [ "${{ steps.source.outputs.is_draft }}" = true ]; then - gh pr ready "${{ inputs.release_pr }}" --repo "${GITHUB_REPOSITORY}" + if [ "$SOURCE_IS_DRAFT" = true ]; then + gh pr ready "$REQUEST_RELEASE_PR" --repo "$GITHUB_REPOSITORY" fi - CURRENT_HEAD="$(gh pr view "${{ inputs.release_pr }}" \ + CURRENT_HEAD="$(gh pr view "$REQUEST_RELEASE_PR" \ --repo "${GITHUB_REPOSITORY}" --json headRefOid --jq .headRefOid)" test "$CURRENT_HEAD" = "$EXPECTED_HEAD" - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -233,8 +279,11 @@ jobs: if: inputs.action == 'candidate' env: RELEASE_APP_TOKEN: ${{ steps.app.outputs.token }} + SOURCE_PR_HEAD: ${{ steps.source.outputs.head_sha }} + SOURCE_PR_BASE: ${{ steps.source.outputs.base_sha }} + SOURCE_PR_TREE: ${{ steps.source.outputs.tree_sha }} run: | - case "${{ inputs.version }}" in + case "$REQUEST_VERSION" in *-rc.[0-9]*) ;; *) echo "Candidate version must end in -rc.N" >&2; exit 1 ;; esac @@ -244,45 +293,97 @@ jobs: process.stdout.write(new Date(value).toISOString()); ')" node cli/scripts/release/index.js candidate \ - --candidate-version "${{ inputs.version }}" \ - --source-pr "${{ inputs.release_pr }}" \ - --source-pr-head "${{ steps.source.outputs.head_sha }}" \ + --candidate-version "$REQUEST_VERSION" \ + --source-pr "$REQUEST_RELEASE_PR" \ + --source-pr-head "$SOURCE_PR_HEAD" \ + --source-pr-base "$SOURCE_PR_BASE" \ + --source-pr-tree "$SOURCE_PR_TREE" \ --timestamp "$CREATED_AT" - npm --prefix cli run release:validate -- --tag "v${{ inputs.version }}" + npm --prefix cli run release:validate -- --tag "v$REQUEST_VERSION" git config user.name guardscan-release-bot git config user.email 41898282+github-actions[bot]@users.noreply.github.com git add cli/package.json cli/package-lock.json cli/CHANGELOG.md .release-candidate.json GIT_AUTHOR_DATE="$CREATED_AT" GIT_COMMITTER_DATE="$CREATED_AT" \ - git commit -m "chore(release): candidate ${{ inputs.version }}" - if git rev-parse --verify "refs/tags/v${{ inputs.version }}" >/dev/null 2>&1; then - test "$(git rev-list -n 1 "v${{ inputs.version }}")" = "$(git rev-parse HEAD)" + git commit -m "chore(release): candidate $REQUEST_VERSION" + if git rev-parse --verify "refs/tags/v$REQUEST_VERSION" >/dev/null 2>&1; then + test "$(git rev-list -n 1 "v$REQUEST_VERSION")" = "$(git rev-parse HEAD)" else - git tag "v${{ inputs.version }}" + git tag "v$REQUEST_VERSION" fi - git push origin "HEAD:refs/heads/release-candidate/v${{ inputs.version }}" - git push origin "refs/tags/v${{ inputs.version }}" + git push origin "HEAD:refs/heads/release-candidate/v$REQUEST_VERSION" + git push origin "refs/tags/v$REQUEST_VERSION" - name: Emit candidate build identity if: inputs.action == 'candidate' id: candidate run: | echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "tag=v${{ inputs.version }}" >> "$GITHUB_OUTPUT" + echo "tag=v$REQUEST_VERSION" >> "$GITHUB_OUTPUT" - - name: Materialize machine promotion decision + - name: Materialize or recover the persisted machine promotion decision if: inputs.action == 'promote' env: GH_TOKEN: ${{ steps.app.outputs.token }} CURRENT_HEAD: ${{ steps.source.outputs.head_sha }} + CURRENT_BASE: ${{ steps.source.outputs.base_sha }} + PR_STATE: ${{ steps.source.outputs.pr_state }} run: | git fetch origin release-ledger - git show "origin/release-ledger:events/v${{ inputs.version }}.jsonl" > release-events.jsonl - RC_TAG="v${{ inputs.version }}" + git worktree add decision-ledger origin/release-ledger + git show "origin/release-ledger:events/v$REQUEST_VERSION.jsonl" > release-events.jsonl + RC_TAG="v$REQUEST_VERSION" git fetch origin "refs/tags/$RC_TAG:refs/tags/$RC_TAG" git switch --detach "$RC_TAG" npm --prefix cli ci - CURRENT_HEAD="$(gh pr view "${{ inputs.release_pr }}" \ - --repo "${GITHUB_REPOSITORY}" --json headRefOid --jq .headRefOid)" - node - <<'NODE' + if [ "$PR_STATE" = MERGED ]; then + node - <<'NODE' + const crypto = require('crypto'); + const fs = require('fs'); + const path = require('path'); + const {readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + const {validateDocument} = require('./cli/scripts/release/lib'); + const events = readEvents('release-events.jsonl'); + const state = materializeReleaseState(events); + const candidate = JSON.parse(fs.readFileSync('.release-candidate.json')); + const pr = JSON.parse(fs.readFileSync('release-pr.json')); + const promotion = [...events].reverse().find(event => ( + event.type === 'promotion_decided' + && event.payload.eligible === true + && event.payload.result === 'permitted' + && /^[a-f0-9]{64}$/.test(event.payload.decisionSha256 || '') + )); + if (!promotion) throw new Error('merged release PR has no permitted persisted promotion decision'); + const matches = fs.readdirSync('decision-ledger/decisions') + .filter(name => name.endsWith('.json')) + .map(name => path.join('decision-ledger/decisions', name)) + .filter(file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') + === promotion.payload.decisionSha256); + if (matches.length !== 1) { + throw new Error('persisted promotion decision cannot be resolved uniquely by digest'); + } + const decision = validateDocument('decision', matches[0], 'cli'); + if (!decision.eligible + || decision.result !== 'permitted' + || decision.rc.version !== state.version + || decision.rc.tag !== state.tag + || decision.rc.commit !== state.commit + || decision.rc.manifestSha256 !== state.manifestSha256 + || decision.stable.sourcePr !== Number(process.env.REQUEST_RELEASE_PR) + || decision.stable.sourcePrHead !== candidate.sourcePrHead + || decision.stable.sourcePrBase !== candidate.sourcePrBase + || decision.stable.sourcePrTree !== candidate.sourcePrTree + || pr.head.sha !== candidate.sourcePrHead + || pr.merged !== true + || !/^[a-f0-9]{40}$/.test(pr.merge_commit_sha || '')) { + throw new Error('persisted promotion decision does not bind the merged release source'); + } + fs.copyFileSync(matches[0], 'promotion-decision.json'); + NODE + else + CURRENT_HEAD="$(gh pr view "$REQUEST_RELEASE_PR" \ + --repo "$GITHUB_REPOSITORY" --json headRefOid --jq .headRefOid)" + CURRENT_BASE="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/$REQUEST_RELEASE_PR" --jq .base.sha)" + export CURRENT_HEAD CURRENT_BASE + node - <<'NODE' const fs = require('fs'); const {readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); const events = readEvents('release-events.jsonl'); @@ -293,7 +394,8 @@ jobs: const canaries = Object.entries(state.canaries).flatMap(([channel, samples]) => samples.map(sample => ({channel, status: sample.status, checkedAt: sample.checkedAt})) ); - const incidents = Object.entries(state.incidents).map(([incidentId, value]) => ({incidentId, ...value})); + const incidents = Object.entries(state.incidents) + .map(([incidentId, value]) => ({incidentId, ...value})); const input = { rc: { version: state.version, @@ -303,8 +405,11 @@ jobs: publishedAt: published.timestamp, sourcePr: candidate.sourcePr, sourcePrHead: candidate.sourcePrHead, + sourcePrBase: candidate.sourcePrBase, + sourcePrTree: candidate.sourcePrTree, }, currentSourcePrHead: process.env.CURRENT_HEAD, + currentSourcePrBase: process.env.CURRENT_BASE, evaluatedAt: new Date().toISOString(), requiredChannels: ['npm', 'pnpm', 'yarn', 'bun', 'github', 'homebrew', 'scoop', 'pypi'], canaries, @@ -312,54 +417,76 @@ jobs: }; fs.writeFileSync('promotion-input.json', `${JSON.stringify(input, null, 2)}\n`); NODE - node cli/scripts/release/index.js promote \ - --promotion-input promotion-input.json \ - --output promotion-decision.json \ - --ledger release-events.jsonl \ - --idempotency-key "promotion:${{ inputs.version }}:${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}" \ - --tag "$RC_TAG" - git worktree add decision-ledger origin/release-ledger - mkdir -p decision-ledger/events decision-ledger/decisions - cp release-events.jsonl "decision-ledger/events/v${{ inputs.version }}.jsonl" - cp promotion-decision.json \ - "decision-ledger/decisions/v${{ inputs.version }}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json" - ( - cd decision-ledger - git config user.name guardscan-release-bot - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add events decisions - git commit -m "promotion decision: v${{ inputs.version }}" - git push origin HEAD:release-ledger - ) + node cli/scripts/release/index.js promote \ + --promotion-input promotion-input.json \ + --output promotion-decision.json \ + --ledger release-events.jsonl \ + --idempotency-key "promotion:$REQUEST_VERSION:${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}" \ + --tag "$RC_TAG" + mkdir -p decision-ledger/events decision-ledger/decisions + cp release-events.jsonl "decision-ledger/events/v$REQUEST_VERSION.jsonl" + cp promotion-decision.json \ + "decision-ledger/decisions/v$REQUEST_VERSION-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json" + ( + cd decision-ledger + git config user.name guardscan-release-bot + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add events decisions + git commit -m "promotion decision: v$REQUEST_VERSION" + git push origin HEAD:release-ledger + ) + fi - name: Auto-merge unchanged stable release PR and create stable tag if: inputs.action == 'promote' id: stable env: GH_TOKEN: ${{ steps.app.outputs.token }} + PR_STATE: ${{ steps.source.outputs.pr_state }} + RECORDED_MERGE_SHA: ${{ steps.source.outputs.merge_sha }} + EXPECTED_HEAD: ${{ steps.source.outputs.head_sha }} run: | STABLE_VERSION="$(node -p 'require("./promotion-decision.json").stable.version')" - gh pr merge "${{ inputs.release_pr }}" \ - --repo "${GITHUB_REPOSITORY}" \ - --auto --squash --match-head-commit "${{ steps.source.outputs.head_sha }}" - for ATTEMPT in $(seq 1 60); do - STATE="$(gh pr view "${{ inputs.release_pr }}" --repo "${GITHUB_REPOSITORY}" --json state --jq .state)" - if [ "$STATE" = MERGED ]; then break; fi - sleep 10 - done - test "$STATE" = MERGED - MERGE_SHA="$(gh pr view "${{ inputs.release_pr }}" \ - --repo "${GITHUB_REPOSITORY}" --json mergeCommit --jq .mergeCommit.oid)" + if [ "$PR_STATE" = OPEN ]; then + EXPECTED_BASE="$(node -p 'require("./promotion-decision.json").stable.sourcePrBase')" + CURRENT_BASE="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$REQUEST_RELEASE_PR" --jq .base.sha)" + test "$CURRENT_BASE" = "$EXPECTED_BASE" + CURRENT_HEAD="$(gh pr view "$REQUEST_RELEASE_PR" \ + --repo "$GITHUB_REPOSITORY" --json headRefOid --jq .headRefOid)" + test "$CURRENT_HEAD" = "$EXPECTED_HEAD" + gh pr merge "$REQUEST_RELEASE_PR" \ + --repo "$GITHUB_REPOSITORY" \ + --squash --match-head-commit "$EXPECTED_HEAD" + for ATTEMPT in $(seq 1 60); do + STATE="$(gh pr view "$REQUEST_RELEASE_PR" --repo "$GITHUB_REPOSITORY" --json state --jq .state)" + if [ "$STATE" = MERGED ]; then break; fi + sleep 10 + done + test "$STATE" = MERGED + MERGE_SHA="$(gh pr view "$REQUEST_RELEASE_PR" \ + --repo "$GITHUB_REPOSITORY" --json mergeCommit --jq .mergeCommit.oid)" + else + MERGE_SHA="$RECORDED_MERGE_SHA" + fi git fetch origin "$MERGE_SHA" + git fetch origin main + git merge-base --is-ancestor "$MERGE_SHA" origin/main git switch --detach "$MERGE_SHA" test "$(node -p 'require("./cli/package.json").version')" = "$STABLE_VERSION" - git tag "v$STABLE_VERSION" - git push origin "refs/tags/v$STABLE_VERSION" + EXPECTED_TREE="$(node -p 'require("./promotion-decision.json").stable.sourcePrTree')" + test "$(git rev-parse "$MERGE_SHA^{tree}")" = "$EXPECTED_TREE" + if git ls-remote --exit-code --tags origin "refs/tags/v$STABLE_VERSION" >/dev/null 2>&1; then + git fetch origin "refs/tags/v$STABLE_VERSION:refs/tags/v$STABLE_VERSION" + test "$(git rev-list -n 1 "v$STABLE_VERSION")" = "$MERGE_SHA" + else + git tag "v$STABLE_VERSION" + git push origin "refs/tags/v$STABLE_VERSION" + fi echo "ref=$MERGE_SHA" >> "$GITHUB_OUTPUT" echo "tag=v$STABLE_VERSION" >> "$GITHUB_OUTPUT" - name: Select build result id: result run: | - if [ "${{ inputs.action }}" = candidate ]; then + if [ "$REQUEST_ACTION" = candidate ]; then echo "ref=${{ steps.candidate.outputs.ref }}" >> "$GITHUB_OUTPUT" echo "tag=${{ steps.candidate.outputs.tag }}" >> "$GITHUB_OUTPUT" echo "channel=rc" >> "$GITHUB_OUTPUT" @@ -369,8 +496,10 @@ jobs: echo "channel=stable" >> "$GITHUB_OUTPUT" fi echo "build=true" >> "$GITHUB_OUTPUT" - echo "release_pr=${{ inputs.release_pr }}" >> "$GITHUB_OUTPUT" + echo "release_pr=$REQUEST_RELEASE_PR" >> "$GITHUB_OUTPUT" echo "source_pr_head=${{ steps.source.outputs.head_sha }}" >> "$GITHUB_OUTPUT" + echo "source_pr_base=${{ steps.source.outputs.base_sha }}" >> "$GITHUB_OUTPUT" + echo "source_pr_tree=${{ steps.source.outputs.tree_sha }}" >> "$GITHUB_OUTPUT" build: name: Build exact tagged release @@ -426,6 +555,18 @@ jobs: with: name: release-payload-${{ needs.prepare.outputs.tag }} path: payload + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-github-evidence-${{ needs.prepare.outputs.tag }} + path: provider-evidence/github + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-npm-evidence-${{ needs.prepare.outputs.tag }} + path: provider-evidence/npm + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-pypi-evidence-${{ needs.prepare.outputs.tag }} + path: provider-evidence/pypi - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: release-catalog-${{ needs.prepare.outputs.tag }} @@ -446,6 +587,8 @@ jobs: RELEASE_TAG: ${{ needs.prepare.outputs.tag }} RELEASE_PR: ${{ needs.prepare.outputs.release_pr }} SOURCE_PR_HEAD: ${{ needs.prepare.outputs.source_pr_head }} + SOURCE_PR_BASE: ${{ needs.prepare.outputs.source_pr_base }} + SOURCE_PR_TREE: ${{ needs.prepare.outputs.source_pr_tree }} RELEASE_CHANNEL: ${{ needs.prepare.outputs.channel }} run: | git fetch origin release-ledger @@ -457,6 +600,7 @@ jobs: const cp = require('child_process'); const {appendEvent, readEvents} = require('./cli/scripts/release/events'); const {releaseTrainChannels} = require('./cli/scripts/release/lib'); + const {createPublicationEvidence} = require('./cli/scripts/release/publication-evidence'); const tag = process.env.RELEASE_TAG; const version = tag.slice(1); const commit = cp.execFileSync('git', ['rev-parse', 'HEAD'], {encoding: 'utf8'}).trim(); @@ -484,6 +628,8 @@ jobs: profile: 'full', releasePr: Number(process.env.RELEASE_PR), sourcePrHead: process.env.SOURCE_PR_HEAD, + sourcePrBase: process.env.SOURCE_PR_BASE, + sourcePrTree: process.env.SOURCE_PR_TREE, channels, }, }); @@ -505,6 +651,43 @@ jobs: artifact.kind === 'standalone' && artifact.platform.os === 'windows' ).map(artifact => artifact.id), }; + const sha256File = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + const canonicalEntries = entries => entries.sort(([left], [right]) => ( + left < right ? -1 : left > right ? 1 : 0 + )); + const expectedProviderFiles = { + github: Object.fromEntries(canonicalEntries(fs.readdirSync('payload').map(filename => [ + filename, + sha256File(`payload/${filename}`), + ]))), + npm: Object.fromEntries(canonicalEntries(manifestDocument.artifacts + .filter(artifact => artifact.kind === 'npm-tarball') + .map(artifact => [artifact.filename, artifact.sha256]))), + pypi: Object.fromEntries(canonicalEntries(manifestDocument.artifacts + .filter(artifact => artifact.kind === 'python-wheel') + .map(artifact => [artifact.filename, artifact.sha256]))), + }; + const providerEvidence = Object.fromEntries(['github', 'npm', 'pypi'].map(channel => { + const filename = `provider-evidence/${channel}/${channel}-publication-evidence.json`; + const evidence = JSON.parse(fs.readFileSync(filename, 'utf8')); + const normalized = createPublicationEvidence(evidence); + if (JSON.stringify(normalized) !== JSON.stringify(evidence) + || evidence.channel !== channel + || evidence.version !== version + || evidence.tag !== tag + || JSON.stringify(evidence.files) !== JSON.stringify(expectedProviderFiles[channel])) { + throw new Error(`${channel} publication evidence does not match exact release artifacts`); + } + const expectedIdentity = { + github: `github:${process.env.GITHUB_REPOSITORY}/releases/tag/${tag}`, + npm: `npm:guardscan@${version}`, + pypi: `pypi:guardscan-cli@${version.replace('-rc.', 'rc')}`, + }[channel]; + if (evidence.remoteIdentity !== expectedIdentity) { + throw new Error(`${channel} publication evidence has an unexpected remote identity`); + } + return [channel, evidence]; + })); const readModeratedEvidence = channel => { const file = `moderated-evidence/${channel}/${channel}-evidence.json`; const evidence = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); @@ -517,7 +700,10 @@ jobs: || typeof evidence.remoteIdentity !== 'string' || evidence.remoteIdentity.length === 0 || !/^[a-f0-9]{64}$/.test(evidence.remoteDigest || '') - || !['submitted', 'pending', 'pending-ledger', 'public-exact'].includes(evidence.state) + || ![ + 'submitted', 'pending', 'pending-ledger', 'accepted', 'public-exact', + 'rejected', 'corrected', 'resubmitted', + ].includes(evidence.state) || typeof evidence.provider?.pendingStateQuery !== 'string') { throw new Error(`${channel} submission evidence is invalid or belongs to another release`); } @@ -563,18 +749,19 @@ jobs: } const normalized = {...evidence}; delete normalized.checkedAt; - normalized.state = evidence.state === 'public-exact' ? 'public-exact' : 'pending'; return normalized; }; for (const channel of ['github', 'npm', 'pypi']) { + const evidence = providerEvidence[channel]; const idempotencyKey = `published:${channel}:${tag}`; appendEvent(ledger, { ...base, timestamp: timestampFor(idempotencyKey), type: 'channel_published', channel, idempotencyKey, payload: { artifactIds: artifactIds[channel], - remoteIdentity: `${channel}:${tag}`, - remoteDigest: manifestSha256, + remoteIdentity: evidence.remoteIdentity, + remoteDigest: evidence.aggregateSha256, + publication: evidence, }, }); } @@ -611,19 +798,29 @@ jobs: if (process.env.RELEASE_CHANNEL === 'stable') { for (const channel of ['winget', 'chocolatey']) { const evidence = readModeratedEvidence(channel); + const eventType = { + accepted: 'channel_accepted', + 'public-exact': 'channel_accepted', + rejected: 'channel_rejected', + corrected: 'channel_corrected', + resubmitted: 'channel_resubmitted', + }[evidence.state] || 'channel_submitted'; const identityKey = crypto.createHash('sha256') .update(evidence.remoteIdentity) .digest('hex') .slice(0, 16); - const idempotencyKey = `submitted:${channel}:${tag}:${evidence.remoteDigest}:${identityKey}`; + const idempotencyKey = `${eventType}:${channel}:${tag}:${evidence.remoteDigest}:${identityKey}`; appendEvent(ledger, { - ...base, timestamp: timestampFor(idempotencyKey), type: 'channel_submitted', channel, + ...base, timestamp: timestampFor(idempotencyKey), type: eventType, channel, idempotencyKey, payload: { artifactIds: artifactIds.scoop, remoteIdentity: evidence.remoteIdentity, remoteDigest: evidence.remoteDigest, submission: evidence, + ...(eventType === 'channel_rejected' + ? {reason: evidence.reason || 'provider rejected the moderated submission'} + : {}), }, }); } @@ -667,10 +864,24 @@ jobs: name: Reconcile remote state and trigger eligible promotion if: github.event_name == 'workflow_dispatch' && inputs.action == 'reconcile' runs-on: ubuntu-24.04 + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_PR: ${{ inputs.release_pr }} concurrency: group: release-ledger cancel-in-progress: false steps: + - name: Validate untrusted reconciliation request before minting credentials + run: | + node - <<'NODE' + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-rc\.(0|[1-9][0-9]*))?$/.test(process.env.RELEASE_VERSION || '')) { + throw new Error('reconciliation version is invalid'); + } + if (!/^[1-9][0-9]*$/.test(process.env.RELEASE_PR || '') + || !Number.isSafeInteger(Number(process.env.RELEASE_PR))) { + throw new Error('reconciliation release PR is invalid'); + } + NODE - name: Create short-lived cross-repository release app token id: app uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 @@ -694,16 +905,15 @@ jobs: GH_TOKEN: ${{ steps.app.outputs.token }} run: | git fetch origin release-ledger - git show "origin/release-ledger:events/v${{ inputs.version }}.jsonl" > release-events.jsonl + git show "origin/release-ledger:events/v$RELEASE_VERSION.jsonl" > release-events.jsonl node cli/scripts/release/index.js reconcile \ --ledger release-events.jsonl \ - --tag "v${{ inputs.version }}" > reconciliation.json + --tag "v$RELEASE_VERSION" > reconciliation.json cat reconciliation.json - name: Authoritatively refetch and reconcile the shared catalog id: catalog env: GH_TOKEN: ${{ steps.app.outputs.token }} - RELEASE_VERSION: ${{ inputs.version }} AUTOMATION_ENABLED: ${{ vars.RELEASE_AUTOMATION_ENABLED }} run: | ACTIVE_CHANNEL="$(gh api \ @@ -852,8 +1062,6 @@ jobs: echo "exact=true" >> "$GITHUB_OUTPUT" - name: Persist refetched catalog publication evidence if: vars.RELEASE_AUTOMATION_ENABLED == 'true' && steps.catalog.outputs.exact == 'true' - env: - RELEASE_VERSION: ${{ inputs.version }} run: | node - <<'NODE' const fs = require('fs'); @@ -899,13 +1107,13 @@ jobs: } NODE git worktree add ledger-branch origin/release-ledger - cp release-events.jsonl "ledger-branch/events/v${{ inputs.version }}.jsonl" + cp release-events.jsonl "ledger-branch/events/v$RELEASE_VERSION.jsonl" ( cd ledger-branch git config user.name guardscan-release-bot git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add "events/v${{ inputs.version }}.jsonl" - git commit -m "catalog reconciled: v${{ inputs.version }}" || exit 0 + git add "events/v$RELEASE_VERSION.jsonl" + git commit -m "catalog reconciled: v$RELEASE_VERSION" || exit 0 git push origin HEAD:release-ledger ) - name: Trigger promotion when the machine policy is eligible @@ -929,27 +1137,59 @@ jobs: gh workflow run release-train.yml \ --repo "${GITHUB_REPOSITORY}" \ -f action=promote \ - -f version="${{ inputs.version }}" \ - -f release_pr="${{ inputs.release_pr }}" + -f version="$RELEASE_VERSION" \ + -f release_pr="$RELEASE_PR" fi + first-release-withdrawal: + name: Withdraw first stable release without a known-good baseline + if: >- + github.event_name == 'workflow_dispatch' + && vars.RELEASE_AUTOMATION_ENABLED == 'true' + && inputs.action == 'rollback' + && inputs.known_good == '' + permissions: + contents: read + pull-requests: read + uses: ./.github/workflows/release-first-withdrawal.yml + with: + version: ${{ inputs.version }} + secrets: inherit + rollback: name: Append rollback and prepare forward fix if: >- github.event_name == 'workflow_dispatch' && vars.RELEASE_AUTOMATION_ENABLED == 'true' && inputs.action == 'rollback' + && inputs.known_good != '' runs-on: ubuntu-24.04 + env: + DEFECTIVE_VERSION: ${{ inputs.version }} + KNOWN_GOOD_VERSION: ${{ inputs.known_good }} concurrency: group: release-ledger cancel-in-progress: false steps: - - name: Create short-lived release app token + - name: Validate untrusted rollback request before minting credentials + run: | + node - <<'NODE' + const stable = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + if (!stable.test(process.env.DEFECTIVE_VERSION || '')) { + throw new Error('rollback defective version is invalid'); + } + if (!stable.test(process.env.KNOWN_GOOD_VERSION || '')) { + throw new Error('verified known-good release is required'); + } + NODE + - name: Create short-lived cross-repository release app token id: app uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ vars.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ntanwir10 + repositories: GuardScan,homebrew-tap - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: v${{ inputs.version }} @@ -960,26 +1200,527 @@ jobs: node-version: ${{ env.RELEASE_NODE_VERSION }} - working-directory: cli run: npm ci - - name: Generate append-only recovery plan + - name: Verify the exact known-good release and protected ledger + id: known-good + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + git fetch origin release-ledger \ + "refs/tags/v$KNOWN_GOOD_VERSION:refs/tags/v$KNOWN_GOOD_VERSION" + KNOWN_GOOD_TAG="v$KNOWN_GOOD_VERSION" + KNOWN_GOOD_COMMIT="$(git rev-list -n 1 "$KNOWN_GOOD_TAG")" + case "$KNOWN_GOOD_COMMIT" in + *[!a-f0-9]*|'') echo "known-good tag has no valid commit" >&2; exit 1 ;; + esac + test "${#KNOWN_GOOD_COMMIT}" = 40 + if ! git show "origin/release-ledger:events/$KNOWN_GOOD_TAG.jsonl" \ + > known-good-events.jsonl; then + echo "known-good release ledger is incomplete" >&2 + exit 1 + fi + mkdir known-good-release + gh release download "$KNOWN_GOOD_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir known-good-release \ + --pattern release-manifest.json + export KNOWN_GOOD_COMMIT KNOWN_GOOD_TAG + node - <<'NODE' + const crypto = require('crypto'); + const fs = require('fs'); + const {readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + const {reconcileRelease} = require('./cli/scripts/release/reconcile'); + const events = readEvents('known-good-events.jsonl'); + const state = materializeReleaseState(events); + const manifestFile = fs.readFileSync('known-good-release/release-manifest.json'); + const manifest = JSON.parse(manifestFile); + const manifestDigest = crypto.createHash('sha256').update(manifestFile).digest('hex'); + if (state.version !== process.env.KNOWN_GOOD_VERSION + || state.tag !== process.env.KNOWN_GOOD_TAG + || state.commit !== process.env.KNOWN_GOOD_COMMIT + || manifest.version !== state.version + || manifest.tag !== state.tag + || manifest.commit !== state.commit) { + throw new Error('known-good release identity does not match its protected ledger'); + } + if (state.manifestSha256 !== manifestDigest) { + throw new Error('known-good manifest digest does not match its protected ledger'); + } + if (!reconcileRelease(state).complete) { + throw new Error('known-good release ledger is incomplete'); + } + const catalogCommits = ['homebrew', 'scoop'] + .map(channel => state.channels[channel]?.catalog?.commit) + .filter(Boolean); + if (catalogCommits.length !== 2 || new Set(catalogCommits).size !== 1) { + throw new Error('known-good catalog evidence is incomplete or inconsistent'); + } + fs.appendFileSync(process.env.GITHUB_OUTPUT, [ + `commit=${process.env.KNOWN_GOOD_COMMIT}`, + `catalog_commit=${catalogCommits[0]}`, + `manifest_sha256=${manifestDigest}`, + '', + ].join('\n')); + NODE + - name: Generate and persist the append-only recovery start env: RELEASE_APP_TOKEN: ${{ steps.app.outputs.token }} + KNOWN_GOOD_COMMIT: ${{ steps.known-good.outputs.commit }} run: | git fetch origin release-ledger - git show "origin/release-ledger:events/v${{ inputs.version }}.jsonl" > release-events.jsonl - ARGS=(--ledger release-events.jsonl \ - --timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \ - --idempotency-key "rollback:${{ inputs.version }}:${GITHUB_RUN_ID}" \ - --tag "v${{ inputs.version }}") - if [ -n "${{ inputs.known_good }}" ]; then ARGS+=(--known-good "${{ inputs.known_good }}"); fi - node cli/scripts/release/index.js rollback "${ARGS[@]}" > rollback-plan.json + DEFECTIVE_TAG="v$DEFECTIVE_VERSION" + git show "origin/release-ledger:events/$DEFECTIVE_TAG.jsonl" > release-events.jsonl + ROLLBACK_KEY="rollback:$DEFECTIVE_VERSION" + export ROLLBACK_KEY + ROLLBACK_TIMESTAMP="$(node - <<'NODE' + const {readEvents} = require('./cli/scripts/release/events'); + const existing = readEvents('release-events.jsonl') + .find(event => event.idempotencyKey === process.env.ROLLBACK_KEY); + process.stdout.write(existing?.timestamp || new Date().toISOString()); + NODE + )" + node cli/scripts/release/index.js rollback \ + --ledger release-events.jsonl \ + --timestamp "$ROLLBACK_TIMESTAMP" \ + --idempotency-key "$ROLLBACK_KEY" \ + --tag "$DEFECTIVE_TAG" \ + --known-good "$KNOWN_GOOD_VERSION" \ + --known-good-commit "$KNOWN_GOOD_COMMIT" \ + > rollback-plan.json cat rollback-plan.json + node - <<'NODE' + const plan = require('./rollback-plan.json'); + if (plan.schemaVersion !== 'guardscan.rollback-plan.v1' + || plan.mode !== 'known-good' + || plan.version !== process.env.DEFECTIVE_VERSION + || plan.knownGood.version !== process.env.KNOWN_GOOD_VERSION + || plan.knownGood.commit !== process.env.KNOWN_GOOD_COMMIT) { + throw new Error('generated rollback plan does not match verified recovery authority'); + } + const {readEvents} = require('./cli/scripts/release/events'); + const events = readEvents('release-events.jsonl'); + for (const action of plan.actions.filter(item => item.automation === 'external-action-required')) { + if (!events.some(event => event.type === 'action_required' && event.channel === action.channel)) { + throw new Error(`rollback ledger is missing external authority action for ${action.channel}`); + } + } + NODE git worktree add ledger-branch origin/release-ledger - cp release-events.jsonl "ledger-branch/events/v${{ inputs.version }}.jsonl" + mkdir -p ledger-branch/events + cp release-events.jsonl "ledger-branch/events/$DEFECTIVE_TAG.jsonl" ( cd ledger-branch git config user.name guardscan-release-bot git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add "events/v${{ inputs.version }}.jsonl" - git commit -m "rollback started: v${{ inputs.version }}" + git add "events/$DEFECTIVE_TAG.jsonl" + git commit -m "rollback started: $DEFECTIVE_TAG" || exit 0 git push origin HEAD:release-ledger ) + - name: Prepare or verify the deterministic forward-fix pull request + id: forward-fix + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + KNOWN_GOOD_COMMIT: ${{ steps.known-good.outputs.commit }} + run: | + FORWARD_FIX_VERSION="$(node -p 'require("./rollback-plan.json").forwardFixVersion')" + FORWARD_FIX_BRANCH="$(node -p 'require("./rollback-plan.json").forwardFixBranch')" + test "$(git rev-list -n 1 "v$KNOWN_GOOD_VERSION")" = "$KNOWN_GOOD_COMMIT" + FORWARD_FIX_REMOTE_HEAD="" + if git fetch origin "refs/heads/$FORWARD_FIX_BRANCH:refs/remotes/origin/$FORWARD_FIX_BRANCH"; then + FORWARD_FIX_REMOTE_HEAD="$(git rev-parse "refs/remotes/origin/$FORWARD_FIX_BRANCH")" + fi + git worktree add forward-fix-source "v$KNOWN_GOOD_VERSION" + git -C forward-fix-source switch -c "$FORWARD_FIX_BRANCH" + export FORWARD_FIX_VERSION + node - <<'NODE' + const {prepareForwardFixSource} = require('./cli/scripts/release/recovery-source'); + const result = prepareForwardFixSource('forward-fix-source', { + knownGoodVersion: process.env.KNOWN_GOOD_VERSION, + defectiveVersion: process.env.DEFECTIVE_VERSION, + forwardFixVersion: process.env.FORWARD_FIX_VERSION, + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + NODE + git -C forward-fix-source config user.name guardscan-release-bot + git -C forward-fix-source config user.email 41898282+github-actions[bot]@users.noreply.github.com + git -C forward-fix-source add cli/CHANGELOG.md cli/package.json cli/package-lock.json + if ! git -C forward-fix-source diff --cached --quiet; then + FORWARD_FIX_CREATED_AT="$(git show -s --format=%cI "$KNOWN_GOOD_COMMIT")" + GIT_AUTHOR_DATE="$FORWARD_FIX_CREATED_AT" GIT_COMMITTER_DATE="$FORWARD_FIX_CREATED_AT" \ + git -C forward-fix-source commit \ + -m "fix(release): forward fix v$FORWARD_FIX_VERSION from v$KNOWN_GOOD_VERSION" + fi + CHANGED_FILES="$(git -C forward-fix-source diff --name-only "$KNOWN_GOOD_COMMIT...HEAD")" + export CHANGED_FILES + node - <<'NODE' + const actual = process.env.CHANGED_FILES.split('\n').filter(Boolean).sort(); + const expected = ['cli/CHANGELOG.md', 'cli/package-lock.json', 'cli/package.json']; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`forward-fix branch contains unreviewed paths: ${actual.join(', ')}`); + } + NODE + FORWARD_FIX_HEAD="$(git -C forward-fix-source rev-parse HEAD)" + FORWARD_FIX_PRS_BEFORE="$(gh pr list --repo "$GITHUB_REPOSITORY" \ + --head "$FORWARD_FIX_BRANCH" --base main --state all --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository)" + export FORWARD_FIX_HEAD FORWARD_FIX_PRS_BEFORE + node - <<'NODE' + const prs = JSON.parse(process.env.FORWARD_FIX_PRS_BEFORE || '[]'); + const repository = process.env.GITHUB_REPOSITORY.toLowerCase(); + const matches = prs.filter(pr => pr.baseRefName === 'main'); + if (matches.some(pr => pr.state === 'CLOSED' && !pr.mergedAt)) { + throw new Error('forward-fix branch has a closed-unmerged pull request'); + } + const merged = matches.find(pr => pr.state === 'MERGED'); + if (merged && ( + merged.headRefOid !== process.env.FORWARD_FIX_HEAD + || merged.headRepository?.nameWithOwner?.toLowerCase() !== repository + )) { + throw new Error('merged forward-fix pull request does not match the deterministic trusted tree'); + } + NODE + if [ -n "$FORWARD_FIX_REMOTE_HEAD" ]; then + git -C forward-fix-source push \ + --force-with-lease="refs/heads/$FORWARD_FIX_BRANCH:$FORWARD_FIX_REMOTE_HEAD" \ + origin "HEAD:refs/heads/$FORWARD_FIX_BRANCH" + else + git -C forward-fix-source push origin "HEAD:refs/heads/$FORWARD_FIX_BRANCH" + fi + FORWARD_FIX_HEAD="$(git -C forward-fix-source rev-parse HEAD)" + FORWARD_FIX_PRS="$(gh pr list --repo "$GITHUB_REPOSITORY" \ + --head "$FORWARD_FIX_BRANCH" --base main --state all --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository)" + export FORWARD_FIX_HEAD FORWARD_FIX_PRS + FORWARD_FIX_PR="$(node - <<'NODE' + const prs = JSON.parse(process.env.FORWARD_FIX_PRS || '[]'); + const repository = process.env.GITHUB_REPOSITORY.toLowerCase(); + const matches = prs.filter(pr => ( + pr.baseRefName === 'main' + )); + if (matches.some(pr => pr.state === 'CLOSED' && !pr.mergedAt)) { + throw new Error('forward-fix branch has a closed-unmerged pull request'); + } + const open = matches.find(pr => pr.state === 'OPEN'); + const merged = matches.find(pr => pr.state === 'MERGED' + && pr.headRefOid === process.env.FORWARD_FIX_HEAD); + if (open && ( + open.headRefOid !== process.env.FORWARD_FIX_HEAD + || open.headRepository?.nameWithOwner?.toLowerCase() !== repository + )) { + throw new Error('forward-fix pull request is not bound to the pushed head'); + } + process.stdout.write(String(open?.number || merged?.number || '')); + NODE + )" + if [ -z "$FORWARD_FIX_PR" ]; then + FORWARD_FIX_PR_ARGS=( + --repo "$GITHUB_REPOSITORY" + --base main + --head "$FORWARD_FIX_BRANCH" + --title "Forward fix GuardScan v$DEFECTIVE_VERSION from verified v$KNOWN_GOOD_VERSION" + --body "Restores the verified known-good source as v$FORWARD_FIX_VERSION. Immutable defective artifacts remain auditable and are superseded through the release ledger." + ) + gh pr create "${FORWARD_FIX_PR_ARGS[@]}" + FORWARD_FIX_PRS="$(gh pr list --repo "$GITHUB_REPOSITORY" \ + --head "$FORWARD_FIX_BRANCH" --base main --state open --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository)" + export FORWARD_FIX_PRS + fi + FORWARD_FIX_PR="$(node - <<'NODE' + const prs = JSON.parse(process.env.FORWARD_FIX_PRS || '[]'); + const repository = process.env.GITHUB_REPOSITORY.toLowerCase(); + const open = prs.find(pr => pr.state === 'OPEN' + && pr.baseRefName === 'main' + && pr.headRepository?.nameWithOwner?.toLowerCase() === repository + && pr.headRefOid === process.env.FORWARD_FIX_HEAD); + const merged = prs.find(pr => pr.state === 'MERGED' + && pr.baseRefName === 'main' + && pr.headRefOid === process.env.FORWARD_FIX_HEAD); + if (!open && !merged) throw new Error('forward-fix pull request is not bound to the pushed head'); + process.stdout.write(String(open?.number || merged?.number || '')); + NODE + )" + echo "version=$FORWARD_FIX_VERSION" >> "$GITHUB_OUTPUT" + echo "branch=$FORWARD_FIX_BRANCH" >> "$GITHUB_OUTPUT" + echo "pull_request=$FORWARD_FIX_PR" >> "$GITHUB_OUTPUT" + echo "commit=$FORWARD_FIX_HEAD" >> "$GITHUB_OUTPUT" + - name: Restore the shared catalog to the exact known-good projection + id: catalog-rollback + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + KNOWN_GOOD_CATALOG_COMMIT: ${{ steps.known-good.outputs.catalog_commit }} + run: | + CATALOG_BRANCH="rollback/v$DEFECTIVE_VERSION-to-v$KNOWN_GOOD_VERSION" + gh repo clone ntanwir10/homebrew-tap catalog-rollback + git -C catalog-rollback fetch origin main + git -C catalog-rollback fetch origin "$KNOWN_GOOD_CATALOG_COMMIT" + CATALOG_BASE="$(git -C catalog-rollback rev-parse origin/main)" + CATALOG_REMOTE_HEAD="" + if git -C catalog-rollback fetch origin \ + "refs/heads/$CATALOG_BRANCH:refs/remotes/origin/$CATALOG_BRANCH"; then + CATALOG_REMOTE_HEAD="$(git -C catalog-rollback rev-parse "refs/remotes/origin/$CATALOG_BRANCH")" + fi + git -C catalog-rollback switch -c "$CATALOG_BRANCH" "$CATALOG_BASE" + git -C catalog-rollback restore --source "$KNOWN_GOOD_CATALOG_COMMIT" -- \ + Formula/guardscan.rb bucket/guardscan.json channel-lock.json + git -C catalog-rollback config user.name guardscan-release-bot + git -C catalog-rollback config user.email 41898282+github-actions[bot]@users.noreply.github.com + git -C catalog-rollback add Formula/guardscan.rb bucket/guardscan.json channel-lock.json + if ! git -C catalog-rollback diff --cached --quiet; then + git -C catalog-rollback commit \ + -m "rollback GuardScan v$DEFECTIVE_VERSION catalog to v$KNOWN_GOOD_VERSION" + fi + CATALOG_CHANGED_FILES="$(git -C catalog-rollback diff --name-only "$CATALOG_BASE...HEAD")" + CATALOG_ALREADY_RESTORED=false + if [ -z "$CATALOG_CHANGED_FILES" ]; then + if [ -z "$CATALOG_REMOTE_HEAD" ]; then + echo "catalog rollback has no projection changes or existing merged recovery" >&2 + exit 1 + fi + CATALOG_HEAD="$CATALOG_REMOTE_HEAD" + CATALOG_PRS="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$CATALOG_BRANCH" --base main --state all --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository,mergeCommit)" + export CATALOG_HEAD CATALOG_PRS + CATALOG_PR_INFO="$(node - <<'NODE' + const prs = JSON.parse(process.env.CATALOG_PRS || '[]'); + const merged = prs.find(pr => pr.state === 'MERGED' + && pr.baseRefName === 'main' + && pr.headRepository?.nameWithOwner?.toLowerCase() === 'ntanwir10/homebrew-tap' + && pr.headRefOid === process.env.CATALOG_HEAD + && /^[a-f0-9]{40}$/.test(pr.mergeCommit?.oid || '')); + if (!merged) throw new Error('catalog already-restored path has no bound merged pull request'); + process.stdout.write(JSON.stringify({ + state: 'MERGED', number: merged.number, mergeCommit: merged.mergeCommit.oid, + })); + NODE + )" + export CATALOG_PR_INFO + CATALOG_PR_STATE=MERGED + CATALOG_PR="$(node -p 'String(JSON.parse(process.env.CATALOG_PR_INFO).number)')" + CATALOG_MERGE_SHA="$(node -p 'JSON.parse(process.env.CATALOG_PR_INFO).mergeCommit')" + CATALOG_ALREADY_RESTORED=true + fi + if [ "$CATALOG_ALREADY_RESTORED" != true ]; then + export CATALOG_CHANGED_FILES + node - <<'NODE' + const actual = process.env.CATALOG_CHANGED_FILES.split('\n').filter(Boolean).sort(); + const allowed = ['Formula/guardscan.rb', 'bucket/guardscan.json', 'channel-lock.json']; + if (actual.some(file => !allowed.includes(file))) { + throw new Error(`catalog rollback branch contains unreviewed paths: ${actual.join(', ')}`); + } + NODE + if [ -n "$CATALOG_REMOTE_HEAD" ]; then + git -C catalog-rollback push \ + --force-with-lease="refs/heads/$CATALOG_BRANCH:$CATALOG_REMOTE_HEAD" \ + origin "HEAD:refs/heads/$CATALOG_BRANCH" + else + git -C catalog-rollback push origin "HEAD:refs/heads/$CATALOG_BRANCH" + fi + CATALOG_HEAD="$(git -C catalog-rollback rev-parse HEAD)" + CATALOG_PRS="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$CATALOG_BRANCH" --base main --state all --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository,mergeCommit)" + export CATALOG_HEAD CATALOG_PRS + CATALOG_PR_INFO="$(node - <<'NODE' + const prs = JSON.parse(process.env.CATALOG_PRS || '[]'); + const repository = 'ntanwir10/homebrew-tap'; + const matches = prs.filter(pr => ( + pr.baseRefName === 'main' + )); + if (matches.some(pr => pr.state === 'CLOSED' && !pr.mergedAt)) { + throw new Error('catalog rollback branch has a closed-unmerged pull request'); + } + const open = matches.find(pr => pr.state === 'OPEN'); + const merged = matches.find(pr => pr.state === 'MERGED' + && pr.headRefOid === process.env.CATALOG_HEAD + && /^[a-f0-9]{40}$/.test(pr.mergeCommit?.oid || '')); + if (open && ( + open.headRefOid !== process.env.CATALOG_HEAD + || open.headRepository?.nameWithOwner?.toLowerCase() !== repository + )) { + throw new Error('catalog rollback pull request is not bound to the pushed head'); + } + process.stdout.write(JSON.stringify({ + state: open ? 'OPEN' : merged ? 'MERGED' : '', + number: open?.number || merged?.number || '', + mergeCommit: merged?.mergeCommit?.oid || '', + })); + NODE + )" + export CATALOG_PR_INFO + CATALOG_PR_STATE="$(node -p 'JSON.parse(process.env.CATALOG_PR_INFO).state')" + CATALOG_PR="$(node -p 'String(JSON.parse(process.env.CATALOG_PR_INFO).number)')" + CATALOG_MERGE_SHA="$(node -p 'JSON.parse(process.env.CATALOG_PR_INFO).mergeCommit')" + if [ -z "$CATALOG_PR" ]; then + gh pr create \ + --repo ntanwir10/homebrew-tap \ + --base main \ + --head "$CATALOG_BRANCH" \ + --title "Rollback GuardScan v$DEFECTIVE_VERSION catalog to v$KNOWN_GOOD_VERSION" \ + --body "Restores the exact cryptographically locked catalog projection from verified v$KNOWN_GOOD_VERSION." + CATALOG_PRS="$(gh pr list --repo ntanwir10/homebrew-tap \ + --head "$CATALOG_BRANCH" --base main --state open --limit 20 \ + --json number,state,mergedAt,headRefOid,baseRefName,headRepository,mergeCommit)" + export CATALOG_PRS + fi + CATALOG_PR_INFO="$(node - <<'NODE' + const prs = JSON.parse(process.env.CATALOG_PRS || '[]'); + const repository = 'ntanwir10/homebrew-tap'; + const matches = prs.filter(pr => ( + pr.baseRefName === 'main' + )); + if (matches.some(pr => pr.state === 'CLOSED' && !pr.mergedAt)) { + throw new Error('catalog rollback branch has a closed-unmerged pull request'); + } + const open = matches.find(pr => pr.state === 'OPEN'); + const merged = matches.find(pr => pr.state === 'MERGED' + && pr.headRefOid === process.env.CATALOG_HEAD + && /^[a-f0-9]{40}$/.test(pr.mergeCommit?.oid || '')); + if (open && ( + open.headRefOid !== process.env.CATALOG_HEAD + || open.headRepository?.nameWithOwner?.toLowerCase() !== repository + )) { + throw new Error('catalog rollback pull request is not bound to the pushed head'); + } + if (!open && !merged) { + throw new Error('catalog rollback pull request is not bound to the pushed head'); + } + process.stdout.write(JSON.stringify({ + state: open ? 'OPEN' : 'MERGED', + number: open?.number || merged?.number, + mergeCommit: merged?.mergeCommit?.oid || '', + })); + NODE + )" + export CATALOG_PR_INFO + CATALOG_PR_STATE="$(node -p 'JSON.parse(process.env.CATALOG_PR_INFO).state')" + CATALOG_PR="$(node -p 'String(JSON.parse(process.env.CATALOG_PR_INFO).number)')" + CATALOG_MERGE_SHA="$(node -p 'JSON.parse(process.env.CATALOG_PR_INFO).mergeCommit')" + if [ "$CATALOG_PR_STATE" = OPEN ]; then + gh pr merge --repo ntanwir10/homebrew-tap "$CATALOG_PR" --squash + for ATTEMPT in $(seq 1 60); do + CATALOG_PR_STATE="$(gh pr view --repo ntanwir10/homebrew-tap "$CATALOG_PR" --json state --jq .state)" + if [ "$CATALOG_PR_STATE" = MERGED ]; then break; fi + sleep 10 + done + test "$CATALOG_PR_STATE" = MERGED + CATALOG_MERGE_SHA="$(gh pr view --repo ntanwir10/homebrew-tap "$CATALOG_PR" \ + --json mergeCommit --jq .mergeCommit.oid)" + fi + fi + case "$CATALOG_MERGE_SHA" in + ''|*[!a-f0-9]*) echo "catalog rollback has no actual merge commit" >&2; exit 1 ;; + esac + test "${#CATALOG_MERGE_SHA}" = 40 + git -C catalog-rollback fetch origin "$CATALOG_MERGE_SHA" + for CATALOG_PATH in Formula/guardscan.rb bucket/guardscan.json channel-lock.json; do + test "$(git -C catalog-rollback rev-parse "$CATALOG_MERGE_SHA:$CATALOG_PATH")" = \ + "$(git -C catalog-rollback rev-parse "$KNOWN_GOOD_CATALOG_COMMIT:$CATALOG_PATH")" + done + echo "branch=$CATALOG_BRANCH" >> "$GITHUB_OUTPUT" + echo "pull_request=$CATALOG_PR" >> "$GITHUB_OUTPUT" + echo "commit=$CATALOG_MERGE_SHA" >> "$GITHUB_OUTPUT" + - name: Complete repository recovery and deactivate the defective train + env: + FORWARD_FIX_VERSION: ${{ steps.forward-fix.outputs.version }} + FORWARD_FIX_BRANCH: ${{ steps.forward-fix.outputs.branch }} + FORWARD_FIX_PR: ${{ steps.forward-fix.outputs.pull_request }} + FORWARD_FIX_COMMIT: ${{ steps.forward-fix.outputs.commit }} + CATALOG_BRANCH: ${{ steps.catalog-rollback.outputs.branch }} + CATALOG_PR: ${{ steps.catalog-rollback.outputs.pull_request }} + CATALOG_COMMIT: ${{ steps.catalog-rollback.outputs.commit }} + run: | + node - <<'NODE' + const fs = require('fs'); + const {appendEvent, readEvents, materializeReleaseState} = require('./cli/scripts/release/events'); + const events = readEvents('release-events.jsonl'); + const state = materializeReleaseState(events); + const now = new Date().toISOString(); + const completionKey = `rollback-repository-completed:${process.env.DEFECTIVE_VERSION}`; + const existingCompletion = events.find(event => event.idempotencyKey === completionKey); + const currentEvidence = { + schemaVersion: 'guardscan.rollback-repository-evidence.v1', + mode: 'known-good', + defectiveVersion: process.env.DEFECTIVE_VERSION, + knownGoodVersion: process.env.KNOWN_GOOD_VERSION, + forwardFix: { + version: process.env.FORWARD_FIX_VERSION, + branch: process.env.FORWARD_FIX_BRANCH, + pullRequest: Number(process.env.FORWARD_FIX_PR), + commit: process.env.FORWARD_FIX_COMMIT, + }, + catalog: { + branch: process.env.CATALOG_BRANCH, + pullRequest: Number(process.env.CATALOG_PR), + commit: process.env.CATALOG_COMMIT, + }, + completedAt: now, + }; + const evidence = existingCompletion?.payload || currentEvidence; + if (existingCompletion) { + const comparable = value => JSON.stringify({...value, completedAt: undefined}); + if (comparable(evidence) !== comparable(currentEvidence)) { + throw new Error('repository rollback retry conflicts with protected completion evidence'); + } + } + fs.writeFileSync('rollback-evidence.json', `${JSON.stringify(evidence, null, 2)}\n`); + appendEvent('release-events.jsonl', { + version: state.version, + tag: state.tag, + commit: state.commit, + timestamp: existingCompletion?.timestamp || now, + type: 'rollback_repository_completed', + idempotencyKey: completionKey, + payload: evidence, + }); + const refreshed = readEvents('release-events.jsonl'); + for (const [channel, channelState] of Object.entries(state.channels)) { + if (!['github', 'pnpm', 'yarn', 'bun', 'homebrew', 'scoop'].includes(channel) + || !['published', 'submitted', 'accepted', 'verified'].includes(channelState.status)) { + continue; + } + const key = `rollback-superseded:${channel}:${process.env.DEFECTIVE_VERSION}`; + const existing = refreshed.find(event => event.idempotencyKey === key); + appendEvent('release-events.jsonl', { + version: state.version, + tag: state.tag, + commit: state.commit, + timestamp: existing?.timestamp || now, + type: 'superseded', + channel, + idempotencyKey: key, + payload: { + artifactIds: channelState.artifactIds, + ...(channelState.remoteIdentity ? {remoteIdentity: channelState.remoteIdentity} : {}), + ...(channelState.remoteDigest ? {remoteDigest: channelState.remoteDigest} : {}), + }, + }); + } + NODE + cp release-events.jsonl "ledger-branch/events/v$DEFECTIVE_VERSION.jsonl" + mkdir -p ledger-branch/recoveries + cp rollback-plan.json "ledger-branch/recoveries/v$DEFECTIVE_VERSION-plan.json" + cp rollback-evidence.json "ledger-branch/recoveries/v$DEFECTIVE_VERSION-evidence.json" + node - <<'NODE' + const fs = require('fs'); + const file = 'ledger-branch/active-versions.json'; + const active = JSON.parse(fs.readFileSync(file, 'utf8')); + active.trains = active.trains.filter( + train => train.version !== process.env.DEFECTIVE_VERSION + ); + fs.writeFileSync(file, `${JSON.stringify(active, null, 2)}\n`); + NODE + ( + cd ledger-branch + git add events active-versions.json recoveries + git commit -m "rollback repository recovery complete: v$DEFECTIVE_VERSION" || exit 0 + git push origin HEAD:release-ledger + ) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rollback-plan-v${{ inputs.version }} + path: | + rollback-plan.json + rollback-evidence.json + if-no-files-found: error + retention-days: 90 diff --git a/cli/__tests__/contracts/release-contracts.test.ts b/cli/__tests__/contracts/release-contracts.test.ts index bcd2ff9..1df592f 100644 --- a/cli/__tests__/contracts/release-contracts.test.ts +++ b/cli/__tests__/contracts/release-contracts.test.ts @@ -213,6 +213,40 @@ function makeState(): JsonDocument { }; } +function makeStateV2(): JsonDocument { + return { + schemaVersion: 'guardscan.release-state.v2', + version: '1.2.0-rc.1', + tag: 'v1.2.0-rc.1', + commit, + updatedAt: timestamp, + lastSequence: 2, + lastEventHash: 'c'.repeat(64), + manifestSha256: digest, + channels: { + npm: { + status: 'published', + artifactIds: ['npm:guardscan@1.2.0-rc.1'], + updatedAt: timestamp, + remoteIdentity: 'npm:guardscan@1.2.0-rc.1', + remoteDigest: digest, + publication: { + schemaVersion: 'guardscan.provider-publication.v1', + channel: 'npm', + version: '1.2.0-rc.1', + tag: 'v1.2.0-rc.1', + remoteIdentity: 'npm:guardscan@1.2.0-rc.1', + aggregateSha256: digest, + files: {'guardscan-1.2.0-rc.1.tgz': digest}, + }, + }, + }, + canaries: {}, + incidents: {}, + actionRequired: [], + }; +} + function makeApproval(): JsonDocument { return { schemaVersion: 'guardscan.release-approval.v1', @@ -240,6 +274,7 @@ describe('release contract schemas', () => { const validateEvent = loadValidator('guardscan.release-event.v1.schema.json'); const validateManifest = loadValidator('guardscan.release-manifest.v1.schema.json'); const validateState = loadValidator('guardscan.release-state.v1.schema.json'); + const validateStateV2 = loadValidator('guardscan.release-state.v2.schema.json'); it('accepts representative release manifest and state documents', () => { const manifest = makeManifest(); @@ -251,6 +286,65 @@ describe('release contract schemas', () => { expect(validateState.errors).toBeNull(); expect(validateApproval(makeApproval())).toBe(true); expect(validateApproval.errors).toBeNull(); + expect(validateStateV2(makeStateV2())).toBe(true); + expect(validateStateV2.errors).toBeNull(); + }); + + it('requires provider evidence for published primary v2 channels and rejects unknown channels', () => { + const missingPublication = clone(makeStateV2()) as { + channels: {npm: Record}; + }; + delete missingPublication.channels.npm.publication; + expect(validateStateV2(missingPublication)).toBe(false); + + const unknownChannel = clone(makeStateV2()) as {channels: Record}; + unknownChannel.channels.unknown = { + status: 'planned', artifactIds: [], updatedAt: timestamp, + }; + expect(validateStateV2(unknownChannel)).toBe(false); + }); + + it('validates exact known-good and first-release recovery state shapes', () => { + const knownGood = clone(makeStateV2()) as Record; + knownGood.recovery = { + status: 'started', + startedAt: timestamp, + mode: 'known-good', + knownGoodVersion: '1.1.9', + knownGoodCommit: 'b'.repeat(40), + forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + }; + expect(validateStateV2(knownGood)).toBe(true); + + const firstRelease = clone(makeStateV2()) as Record; + firstRelease.recovery = { + status: 'provider-actions-pending', + startedAt: timestamp, + repositoryCompletedAt: timestamp, + mode: 'first-release-withdrawal', + requiredNextVersion: '1.2.1', + externalActionsPending: ['npm'], + evidence: {schemaVersion: 'guardscan.rollback-repository-evidence.v1'}, + }; + firstRelease.incidents = { + 'first-release-withdrawal-v1.2.0': { + kind: 'recovery', + status: 'open', + openedAt: timestamp, + summary: 'The first stable release requires provider withdrawal', + }, + }; + expect(validateStateV2(firstRelease)).toBe(true); + + firstRelease.recovery.knownGoodVersion = '1.1.9'; + expect(validateStateV2(firstRelease)).toBe(false); + delete firstRelease.recovery.knownGoodVersion; + delete firstRelease.recovery.mode; + expect(validateStateV2(firstRelease)).toBe(false); + firstRelease.recovery.mode = 'first-release-withdrawal'; + firstRelease.incidents['first-release-withdrawal-v1.2.0'].kind = 'deployment'; + expect(validateStateV2(firstRelease)).toBe(false); }); it('binds the shared channel catalog to GuardScan source, generator, and file digests', () => { @@ -302,6 +396,64 @@ describe('release contract schemas', () => { })).toBe(true); }); + it('validates both rollback modes and exact first-release repository evidence', () => { + const base = { + schemaVersion: 'guardscan.release-event.v1', + version: '1.2.0', + tag: 'v1.2.0', + commit, + sequence: 2, + previousHash: 'c'.repeat(64), + timestamp, + idempotencyKey: 'rollback:v1.2.0', + eventHash: 'd'.repeat(64), + }; + expect(validateEvent({ + ...base, + type: 'rollback_started', + payload: {mode: 'first-release-withdrawal', requiredNextVersion: '1.2.1'}, + })).toBe(true); + expect(validateEvent({ + ...base, + type: 'rollback_started', + payload: { + mode: 'known-good', + knownGoodVersion: '1.1.9', + knownGoodCommit: 'b'.repeat(40), + forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + }, + })).toBe(true); + expect(validateEvent({ + ...base, + type: 'rollback_started', + payload: { + mode: 'first-release-withdrawal', + requiredNextVersion: '1.2.1', + knownGoodVersion: '1.1.9', + }, + })).toBe(false); + expect(validateEvent({ + ...base, + type: 'rollback_repository_completed', + idempotencyKey: 'rollback-repository-completed:1.2.0', + payload: { + schemaVersion: 'guardscan.rollback-repository-evidence.v1', + mode: 'first-release-withdrawal', + defectiveVersion: '1.2.0', + requiredNextVersion: '1.2.1', + externalActionsPending: ['npm'], + catalog: { + state: 'already-absent', + branch: 'rollback/v1.2.0-remove-first-release', + pullRequest: 0, + commit: 'e'.repeat(40), + }, + completedAt: timestamp, + }, + })).toBe(true); + }); + it('rejects broad, malformed, and untrusted promotion approvals', () => { const wrongEnvironment = clone(makeApproval()) as { evidence: { environment: string }; diff --git a/cli/__tests__/integration/ai-providers-enhanced.test.ts b/cli/__tests__/integration/ai-providers-enhanced.test.ts index 8f41c56..0907958 100644 --- a/cli/__tests__/integration/ai-providers-enhanced.test.ts +++ b/cli/__tests__/integration/ai-providers-enhanced.test.ts @@ -4,21 +4,31 @@ * Tests the full decorator stack and end-to-end scenarios. */ -import { describe, expect, it, jest } from '@jest/globals'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { ProviderFactory } from '../../src/providers/factory'; import { AIProvider, AIMessage } from '../../src/providers/base'; import { MetricsCollector } from '../../src/core/metrics-collector'; import { Config } from '../../src/core/config'; +import { RetryProvider } from '../../src/providers/decorators/retry-provider'; +import { + CircuitBreakerProvider, + CircuitState, +} from '../../src/providers/decorators/circuit-breaker-provider'; +import { CachedProvider } from '../../src/providers/decorators/cached-provider'; +import { RateLimitedProvider } from '../../src/providers/decorators/rate-limited-provider'; +import { ObservableProvider } from '../../src/providers/decorators/observable-provider'; // Mock provider for testing class TestProvider extends AIProvider { private failureCount = 0; private maxFailures: number; private callCount = 0; + private providerName: string; - constructor(maxFailures: number = 0) { + constructor(maxFailures: number = 0, providerName: string = 'Test') { super(); this.maxFailures = maxFailures; + this.providerName = providerName; } async chat(messages: AIMessage[]) { @@ -55,7 +65,7 @@ class TestProvider extends AIProvider { } getName() { - return 'Test'; + return this.providerName; } async testConnection() { @@ -77,6 +87,15 @@ class TestProvider extends AIProvider { } describe('Enhanced AI Provider Integration', () => { + const uniqueId = (prefix: string) => ( + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + const messages: AIMessage[] = [{ role: 'user', content: 'Explain this code path.' }]; + + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('decorator stack', () => { it('should create provider with all decorators', () => { const config: Config = { @@ -130,37 +149,137 @@ describe('Enhanced AI Provider Integration', () => { describe('retry with circuit breaker', () => { it('should retry failures and track in circuit breaker', async () => { - // This test verifies retry and circuit breaker work together - // In practice, this would use mocked providers - expect(true).toBe(true); // Placeholder + const warning = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const base = new TestProvider(1, uniqueId('retry-circuit')); + const retry = new RetryProvider(base, { + maxRetries: 1, + baseDelayMs: 0, + maxDelayMs: 0, + jitterFactor: 0, + }); + const circuit = new CircuitBreakerProvider(retry, { + failureThreshold: 1, + resetTimeoutMs: 60_000, + halfOpenSuccessThreshold: 1, + monitoredErrors: ['500'], + }); + + await expect(circuit.chat(messages)).resolves.toMatchObject({ content: 'Success' }); + expect(base.getCallCount()).toBe(2); + expect(circuit.getState()).toBe(CircuitState.CLOSED); + expect(circuit.getStats()).toMatchObject({ + totalFailures: 0, + totalSuccesses: 1, + circuitOpenCount: 0, + }); + expect(warning).toHaveBeenCalledTimes(1); }); }); describe('caching with observability', () => { it('should track cache hits in metrics', async () => { - // This test verifies cache hits are properly tracked by observability - expect(true).toBe(true); // Placeholder + const id = uniqueId('cache-observability'); + const base = new TestProvider(0, id); + const cached = new CachedProvider(base, id, undefined, { + useSemanticSimilarity: false, + }); + const metrics = new MetricsCollector(id); + const observable = new ObservableProvider(cached, metrics); + + const first = await observable.chat(messages); + const second = await observable.chat(messages); + + expect(first.content).toBe('Success'); + expect(second.model).toContain('cached'); + expect(base.getCallCount()).toBe(1); + expect(cached.getCacheStats()).toMatchObject({ hits: 1, misses: 1 }); + expect(metrics.getMetrics()).toMatchObject({ + totalCalls: 2, + successRate: 100, + cacheHitRate: 50, + }); + expect(metrics.getSpans().map(span => span.cacheHit)).toEqual([false, true]); + + await cached.clearCache(); }); }); describe('rate limiting with retry', () => { it('should rate limit and retry on failures', async () => { - // This test verifies rate limiting and retry work together - expect(true).toBe(true); // Placeholder + const warning = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const base = new TestProvider(1, uniqueId('rate-retry')); + const retry = new RetryProvider(base, { + maxRetries: 1, + baseDelayMs: 0, + maxDelayMs: 0, + jitterFactor: 0, + }); + const rateLimited = new RateLimitedProvider(retry, { + maxTokens: 100, + refillRate: 1, + costMultiplier: 1, + }); + + await expect(rateLimited.chat(messages)).resolves.toMatchObject({ content: 'Success' }); + expect(base.getCallCount()).toBe(2); + expect(rateLimited.getStats()).toMatchObject({ + maxTokens: 100, + totalWaits: 0, + }); + expect(rateLimited.getStats().currentTokens).toBeLessThan(100); + expect(warning).toHaveBeenCalledTimes(1); }); }); describe('full stack end-to-end', () => { it('should handle complex scenario with all features', async () => { - // This test runs a full scenario: - // 1. Request made - // 2. Rate limited (waits) - // 3. Fails first time - // 4. Retries successfully - // 5. Cached for next request - // 6. All tracked by observability - // 7. Circuit breaker stays closed - expect(true).toBe(true); // Placeholder + const warning = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const id = uniqueId('full-stack'); + const base = new TestProvider(1, id); + const retry = new RetryProvider(base, { + maxRetries: 1, + baseDelayMs: 0, + maxDelayMs: 0, + jitterFactor: 0, + }); + const rateLimited = new RateLimitedProvider(retry, { + maxTokens: 1_000, + refillRate: 1, + costMultiplier: 1, + }); + const circuit = new CircuitBreakerProvider(rateLimited, { + failureThreshold: 1, + resetTimeoutMs: 60_000, + halfOpenSuccessThreshold: 1, + monitoredErrors: ['500'], + }); + const cached = new CachedProvider(circuit, id, undefined, { + useSemanticSimilarity: false, + }); + const metrics = new MetricsCollector(id); + const enhanced = new ObservableProvider(cached, metrics); + + const first = await enhanced.chat(messages); + const second = await enhanced.chat(messages); + + expect(first.content).toBe('Success'); + expect(second.model).toContain('cached'); + expect(base.getCallCount()).toBe(2); + expect(rateLimited.getStats().currentTokens).toBeLessThan(1_000); + expect(circuit.getStats()).toMatchObject({ + state: CircuitState.CLOSED, + totalFailures: 0, + totalSuccesses: 1, + }); + expect(cached.getCacheStats()).toMatchObject({ hits: 1, misses: 1 }); + expect(metrics.getMetrics()).toMatchObject({ + totalCalls: 2, + successRate: 100, + cacheHitRate: 50, + }); + expect(warning).toHaveBeenCalledTimes(1); + + await cached.clearCache(); }); }); }); diff --git a/cli/__tests__/scripts/release-train.test.ts b/cli/__tests__/scripts/release-train.test.ts index 40cde45..c0f0adb 100644 --- a/cli/__tests__/scripts/release-train.test.ts +++ b/cli/__tests__/scripts/release-train.test.ts @@ -1,3 +1,5 @@ +import crypto from 'crypto'; +import {execFileSync} from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -5,10 +7,15 @@ import zlib from 'zlib'; const { appendEvent, + createEvent, materializeReleaseState, readEvents, } = require('../../scripts/release/events') as { appendEvent: (file: string, input: Record) => Record; + createEvent: ( + input: Record, + previous?: Record + ) => Record; materializeReleaseState: (events: Array>) => Record; readEvents: (file: string) => Array>; }; @@ -32,6 +39,23 @@ const { } = require('../../scripts/release/promotion') as { createPromotionDecision: (input: Record) => Record; }; +const { + createPublicationEvidence, +} = require('../../scripts/release/publication-evidence') as { + createPublicationEvidence: (input: Record) => Record; +}; +const { + handlePublication, + main, +} = require('../../scripts/release/index') as { + handlePublication: ( + command: string, + source: Record, + manifest: Record, + options: Record + ) => Record; + main: (argv: string[]) => Promise; +}; const { classifyRemoteArtifact, } = require('../../scripts/release/remote') as { @@ -41,9 +65,14 @@ const { ) => Record; }; const { + planFirstReleaseWithdrawal, planRollback, reconcileRelease, } = require('../../scripts/release/reconcile') as { + planFirstReleaseWithdrawal: ( + state: Record, + authority: Record + ) => Record; planRollback: ( state: Record, knownGoodVersion?: string, @@ -51,6 +80,27 @@ const { ) => Record; reconcileRelease: (state: Record) => Record; }; +const { + assertFirstReleaseWithdrawal, + prepareFirstReleaseCatalogWithdrawal, +} = require('../../scripts/release/first-release-withdrawal') as { + assertFirstReleaseWithdrawal: ( + ledgerRoot: string, + defectiveVersion: string, + ledgerCommit: string + ) => Record; + prepareFirstReleaseCatalogWithdrawal: ( + catalogRoot: string, + defectiveVersion: string, + defectiveCommit: string + ) => Record; +}; +const {releaseTrainChannels} = require('../../scripts/release/lib') as { + releaseTrainChannels: ( + channel: string, + options?: {homebrewCoreEnabled?: boolean} + ) => string[]; +}; const { prepareForwardFixSource, } = require('../../scripts/release/recovery-source') as { @@ -98,7 +148,7 @@ function eventInput( timestamp: new Date(Date.parse(timestamp) + sequence * 60_000).toISOString(), type, idempotencyKey: `${type}:${sequence}`, - payload: {}, + payload: type === 'train_started' ? {channels: ['npm']} : {}, ...overrides, }; } @@ -195,6 +245,130 @@ function wheel(native: Record, digest: string): Record } describe('append-only release train', () => { + it('canonicalizes provider files with the same code-point ordering used by release workflows', () => { + const files = { + 'guardscan-linux-x64.tar.gz': 'c'.repeat(64), + 'SHA256SUMS.sigstore.json': 'b'.repeat(64), + SHA256SUMS: 'a'.repeat(64), + }; + const evidence = createPublicationEvidence({ + channel: 'github', + version: source.version, + tag: source.tag, + remoteIdentity: `github:ntanwir10/GuardScan/releases/tag/${source.tag}`, + files, + }); + expect(Object.keys(evidence.files)).toEqual(Object.keys(files).sort()); + expect(createPublicationEvidence({...evidence, files: {...files}})).toEqual(evidence); + }); + + it('requires exact provider evidence for primary publication events and the CLI', () => { + const first = createEvent(eventInput('train_started', 0, { + payload: {channels: ['npm']}, + })); + const artifact = { + id: `npm:guardscan@${source.version}`, + kind: 'npm-tarball', + filename: `guardscan-${source.version}.tgz`, + sha256: 'b'.repeat(64), + }; + const remoteIdentity = `npm:guardscan@${source.version}`; + expect(() => createEvent(eventInput('channel_published', 1, { + channel: 'npm', + payload: {remoteIdentity, remoteDigest: artifact.sha256}, + }), first)).toThrow(/requires provider-bound file evidence/); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-publication-evidence-')); + const ledger = path.join(root, 'ledger.jsonl'); + const evidenceFile = path.join(root, 'npm-publication-evidence.json'); + try { + fs.writeFileSync(ledger, `${JSON.stringify(first)}\n`); + const publication = createPublicationEvidence({ + channel: 'npm', + version: source.version, + tag: source.tag, + remoteIdentity, + files: {[artifact.filename]: artifact.sha256}, + }); + fs.writeFileSync(evidenceFile, `${JSON.stringify(publication, null, 2)}\n`); + const options = { + manifest: path.join(root, 'release-manifest.json'), + ledger, + channel: 'npm', + artifactId: artifact.id, + remoteIdentity, + remoteDigest: publication.aggregateSha256, + timestamp: eventInput('channel_published', 1).timestamp, + idempotencyKey: 'published:npm:test', + }; + expect(() => handlePublication('publish', source, {artifacts: [artifact]}, options)) + .toThrow(/requires --publication-evidence/); + expect(handlePublication('publish', source, {artifacts: [artifact]}, { + ...options, + publicationEvidence: evidenceFile, + }).event.payload.publication).toEqual(publication); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('rejects unknown train channels and terminal or backward channel transitions', () => { + expect(() => createEvent(eventInput('train_started', 0, { + payload: {channels: ['npm', 'npm']}, + }))).toThrow(/non-empty unique supported channel list/); + expect(() => createEvent(eventInput('train_started', 0, { + payload: {channels: ['unknown']}, + }))).toThrow(/non-empty unique supported channel list/); + + const first = createEvent(eventInput('train_started', 0, { + payload: {channels: ['npm']}, + })); + const publication = createPublicationEvidence({ + channel: 'npm', + version: source.version, + tag: source.tag, + remoteIdentity: `npm:guardscan@${source.version}`, + files: {[`guardscan-${source.version}.tgz`]: 'b'.repeat(64)}, + }); + const publishedInput = eventInput('channel_published', 1, { + channel: 'npm', + payload: { + remoteIdentity: publication.remoteIdentity, + remoteDigest: publication.aggregateSha256, + publication, + }, + }); + const published = createEvent(publishedInput, first); + const verified = createEvent(eventInput('channel_verified', 2, { + channel: 'npm', + }), published); + const regression = createEvent(eventInput('channel_published', 3, { + channel: 'npm', + payload: publishedInput.payload, + }), verified); + expect(() => materializeReleaseState([first, published, verified, regression])) + .toThrow(/cannot move npm from verified to published/); + + const superseded = createEvent(eventInput('superseded', 3, { + channel: 'npm', + }), verified); + const reopened = createEvent(eventInput('channel_published', 4, { + channel: 'npm', + payload: publishedInput.payload, + }), superseded); + expect(() => materializeReleaseState([first, published, verified, superseded, reopened])) + .toThrow(/cannot move npm from superseded to published/); + + const catalogStarted = createEvent(eventInput('train_started', 0, { + payload: {channels: ['homebrew']}, + })); + const catalogVerified = createEvent(eventInput('channel_verified', 1, { + channel: 'homebrew', + }), catalogStarted); + expect(materializeReleaseState([catalogStarted, catalogVerified]).channels.homebrew.status) + .toBe('verified'); + }); + it('prepares deterministic forward-fix source from the exact known-good tree', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-forward-fix-')); const cli = path.join(root, 'cli'); @@ -257,6 +431,13 @@ describe('append-only release train', () => { artifactIds: ['npm:guardscan@1.2.0-rc.1'], remoteIdentity: 'guardscan@1.2.0-rc.1', remoteDigest: 'b'.repeat(64), + publication: createPublicationEvidence({ + channel: 'npm', + version: source.version, + tag: source.tag, + remoteIdentity: 'guardscan@1.2.0-rc.1', + files: {'guardscan-1.2.0-rc.1.tgz': 'b'.repeat(64)}, + }), }, }); expect(appendEvent(ledger, published).changed).toBe(true); @@ -271,6 +452,7 @@ describe('append-only release train', () => { })); appendEvent(ledger, eventInput('rollback_started', 3, { payload: { + mode: 'known-good', knownGoodVersion: '1.1.9', knownGoodCommit: 'b'.repeat(40), forwardFixVersion: '1.2.1', @@ -287,7 +469,11 @@ describe('append-only release train', () => { })); appendEvent(ledger, eventInput('superseded', 5, { channel: 'npm', - payload: published.payload, + payload: { + artifactIds: published.payload.artifactIds, + remoteIdentity: published.payload.remoteIdentity, + remoteDigest: published.payload.remoteDigest, + }, })); const state = materializeReleaseState(readEvents(ledger)); @@ -306,6 +492,10 @@ describe('append-only release train', () => { status: 'superseded', artifactIds: ['npm:guardscan@1.2.0-rc.1'], remoteDigest: 'b'.repeat(64), + publication: expect.objectContaining({ + schemaVersion: 'guardscan.provider-publication.v1', + aggregateSha256: 'b'.repeat(64), + }), }, }, }); @@ -347,6 +537,62 @@ describe('append-only release train', () => { }); expect(() => planRollback(rollbackInput)).toThrow(/verified known-good version is required/); expect(() => planRollback(rollbackInput, '1.1.9')).toThrow(/known-good commit is required/); + + const firstRelease = { + ...rollbackInput, + version: '1.2.0', + tag: 'v1.2.0', + channels: { + npm: {...rollbackInput.channels.npm, status: 'verified'}, + homebrew: {status: 'verified'}, + scoop: {status: 'verified'}, + }, + }; + const firstReleaseAuthority = { + schemaVersion: 'guardscan.first-release-withdrawal-authority.v1', + verified: true, + defectiveVersion: '1.2.0', + defectiveTag: 'v1.2.0', + defectiveCommit: commit, + ledgerCommit: 'c'.repeat(40), + priorCompleteStableVersions: [], + }; + expect(planFirstReleaseWithdrawal(firstRelease, firstReleaseAuthority)).toMatchObject({ + schemaVersion: 'guardscan.rollback-plan.v1', + mode: 'first-release-withdrawal', + requiredNextVersion: '1.2.1', + authority: { + ledgerCommit: 'c'.repeat(40), + priorCompleteStableVersions: [], + }, + repositoryActions: expect.arrayContaining([ + expect.objectContaining({id: 'open-recovery-incident'}), + expect.objectContaining({id: 'shared-catalog-withdrawal'}), + expect.objectContaining({id: 'deactivate-train'}), + ]), + actions: expect.arrayContaining([ + expect.objectContaining({ + channel: 'npm', + action: 'deprecate-defective-release', + automation: 'external-action-required', + }), + expect.objectContaining({ + channel: 'homebrew', + action: 'remove-first-release-listing', + automation: 'repository-automated', + }), + ]), + }); + expect(planFirstReleaseWithdrawal(firstRelease, firstReleaseAuthority)) + .not.toHaveProperty('knownGood'); + expect(planFirstReleaseWithdrawal(firstRelease, firstReleaseAuthority)) + .not.toHaveProperty('forwardFixBranch'); + expect(() => planFirstReleaseWithdrawal(firstRelease, { + ...firstReleaseAuthority, + priorCompleteStableVersions: ['1.1.9'], + })).toThrow(/requires exact protected-ledger authority/); + expect(() => planFirstReleaseWithdrawal(rollbackInput, firstReleaseAuthority)) + .toThrow(/requires a stable defective release/); } finally { fs.rmSync(root, {recursive: true, force: true}); } @@ -364,6 +610,168 @@ describe('append-only release train', () => { } }); + it('permits first-release withdrawal only without a verified stable predecessor', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-first-withdrawal-')); + const eventsRoot = path.join(root, 'events'); + fs.mkdirSync(eventsRoot); + const stableEvent = ( + version: string, + type: string, + sequence: number, + overrides: Record = {} + ) => eventInput(type, sequence, { + version, + tag: `v${version}`, + ...overrides, + }); + try { + const defectiveLedger = path.join(eventsRoot, 'v1.2.0.jsonl'); + const stableChannels = releaseTrainChannels('stable'); + appendEvent(defectiveLedger, stableEvent('1.2.0', 'train_started', 0, { + payload: {channels: stableChannels}, + })); + fs.writeFileSync(path.join(root, 'active-versions.json'), `${JSON.stringify({ + schemaVersion: 'guardscan.active-trains.v1', + trains: [{version: '1.2.0', releasePr: 32, channel: 'stable'}], + })}\n`); + expect(assertFirstReleaseWithdrawal(root, '1.2.0', 'd'.repeat(40))).toEqual({ + schemaVersion: 'guardscan.first-release-withdrawal-authority.v1', + verified: true, + defectiveVersion: '1.2.0', + defectiveTag: 'v1.2.0', + defectiveCommit: commit, + ledgerCommit: 'd'.repeat(40), + priorCompleteStableVersions: [], + alreadyCompleted: false, + }); + appendEvent(defectiveLedger, stableEvent('1.2.0', 'rollback_started', 1, { + payload: {mode: 'first-release-withdrawal', requiredNextVersion: '1.2.1'}, + })); + let sequence = 2; + for (const channel of stableChannels) { + appendEvent(defectiveLedger, stableEvent('1.2.0', 'withdrawn', sequence, { + channel, + payload: {artifactIds: []}, + })); + sequence += 1; + } + appendEvent(defectiveLedger, stableEvent('1.2.0', 'rollback_repository_completed', sequence, { + payload: { + schemaVersion: 'guardscan.rollback-repository-evidence.v1', + mode: 'first-release-withdrawal', + defectiveVersion: '1.2.0', + requiredNextVersion: '1.2.1', + externalActionsPending: [], + catalog: { + state: 'already-absent', + branch: 'rollback/v1.2.0-remove-first-release', + pullRequest: 0, + commit: 'd'.repeat(40), + }, + completedAt: new Date(Date.parse(timestamp) + sequence * 60_000).toISOString(), + }, + })); + fs.writeFileSync(path.join(root, 'active-versions.json'), `${JSON.stringify({ + schemaVersion: 'guardscan.active-trains.v1', + trains: [], + })}\n`); + expect(assertFirstReleaseWithdrawal(root, '1.2.0', 'd'.repeat(40))).toMatchObject({ + alreadyCompleted: true, + }); + fs.writeFileSync(path.join(root, 'active-versions.json'), `${JSON.stringify({ + schemaVersion: 'guardscan.active-trains.v1', + trains: [{version: '1.2.0', releasePr: 32, channel: 'stable'}], + })}\n`); + expect(() => assertFirstReleaseWithdrawal(root, '1.2.0', 'd'.repeat(40))) + .toThrow(/not an active protected train/); + fs.writeFileSync(path.join(root, 'active-versions.json'), `${JSON.stringify({ + schemaVersion: 'guardscan.active-trains.v1', + trains: [], + })}\n`); + + const laterLedger = path.join(eventsRoot, 'v1.2.1.jsonl'); + appendEvent(laterLedger, stableEvent('1.2.1', 'train_started', 0, { + payload: {channels: ['pnpm']}, + })); + appendEvent(laterLedger, stableEvent('1.2.1', 'channel_verified', 1, { + channel: 'pnpm', + })); + expect(assertFirstReleaseWithdrawal(root, '1.2.0', 'd'.repeat(40))).toMatchObject({ + alreadyCompleted: true, + }); + + const priorLedger = path.join(eventsRoot, 'v1.1.9.jsonl'); + appendEvent(priorLedger, stableEvent('1.1.9', 'train_started', 0, { + payload: {channels: ['pnpm']}, + })); + appendEvent(priorLedger, stableEvent('1.1.9', 'channel_verified', 1, { + channel: 'pnpm', + })); + expect(() => assertFirstReleaseWithdrawal(root, '1.2.0', 'd'.repeat(40))) + .toThrow(/verified stable predecessors exist \(1.1.9\)/); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('removes only a complete catalog projection for the defective first release', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-catalog-withdrawal-')); + try { + fs.mkdirSync(path.join(root, 'Formula')); + fs.mkdirSync(path.join(root, 'bucket')); + const formula = 'class Guardscan < Formula\n version "1.2.0"\nend\n'; + const scoop = `${JSON.stringify({version: '1.2.0'})}\n`; + fs.writeFileSync(path.join(root, 'Formula/guardscan.rb'), formula); + fs.writeFileSync(path.join(root, 'bucket/guardscan.json'), scoop); + const lock = { + schemaVersion: 'guardscan.channel-catalog.v1', + source: { + repository: 'ntanwir10/GuardScan', + version: '1.2.0', + tag: 'v1.2.0', + commit, + manifestUrl: 'https://github.com/ntanwir10/GuardScan/releases/download/v1.2.0/release-manifest.json', + manifestSha256: 'f'.repeat(64), + }, + generator: {repository: 'ntanwir10/GuardScan', commit}, + files: { + 'Formula/guardscan.rb': { + sha256: crypto.createHash('sha256').update(formula).digest('hex'), + }, + 'bucket/guardscan.json': { + sha256: crypto.createHash('sha256').update(scoop).digest('hex'), + }, + }, + }; + fs.writeFileSync(path.join(root, 'channel-lock.json'), `${JSON.stringify({ + ...lock, + files: { + ...lock.files, + 'Formula/guardscan.rb': {sha256: '0'.repeat(64)}, + }, + })}\n`); + expect(() => prepareFirstReleaseCatalogWithdrawal(root, '1.2.0', commit)) + .toThrow(/does not exactly identify/); + fs.writeFileSync(path.join(root, 'channel-lock.json'), `${JSON.stringify(lock)}\n`); + expect(prepareFirstReleaseCatalogWithdrawal(root, '1.2.0', commit)).toEqual({ + changed: true, + state: 'removed', + removed: [ + 'Formula/guardscan.rb', + 'bucket/guardscan.json', + 'channel-lock.json', + ], + }); + expect(prepareFirstReleaseCatalogWithdrawal(root, '1.2.0', commit)) + .toEqual({changed: false, state: 'already-absent', removed: []}); + fs.writeFileSync(path.join(root, 'channel-lock.json'), '{}\n'); + expect(() => prepareFirstReleaseCatalogWithdrawal(root, '1.2.0', commit)) + .toThrow(/catalog is partial/); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + it('rejects malformed external recovery authority events', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-ledger-action-')); const ledger = path.join(root, 'ledger.jsonl'); @@ -379,6 +787,267 @@ describe('append-only release train', () => { fs.rmSync(root, {recursive: true, force: true}); } }); + + it('rejects conflicting recovery starts and malformed withdrawal completion evidence', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-recovery-conflict-')); + const ledger = path.join(root, 'ledger.jsonl'); + const stable = (type: string, sequence: number, overrides: Record = {}) => ( + eventInput(type, sequence, { + version: '1.2.0', + tag: 'v1.2.0', + ...overrides, + }) + ); + try { + appendEvent(ledger, stable('train_started', 0, { + payload: {channels: ['npm']}, + })); + appendEvent(ledger, stable('rollback_started', 1, { + payload: {mode: 'first-release-withdrawal', requiredNextVersion: '1.2.1'}, + })); + appendEvent(ledger, stable('rollback_started', 2, { + payload: { + mode: 'known-good', + knownGoodVersion: '1.1.9', + knownGoodCommit: 'b'.repeat(40), + forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + }, + })); + expect(() => materializeReleaseState(readEvents(ledger))) + .toThrow(/recovery cannot be started more than once/); + + const invalidLedger = path.join(root, 'invalid.jsonl'); + appendEvent(invalidLedger, stable('train_started', 0, { + payload: {channels: ['npm']}, + })); + appendEvent(invalidLedger, stable('rollback_started', 1, { + payload: {mode: 'first-release-withdrawal', requiredNextVersion: '1.2.1'}, + })); + expect(() => appendEvent(invalidLedger, stable('rollback_repository_completed', 2, { + payload: { + schemaVersion: 'guardscan.rollback-repository-evidence.v1', + mode: 'first-release-withdrawal', + defectiveVersion: '1.2.0', + requiredNextVersion: '1.2.1', + externalActionsPending: [], + catalog: { + state: 'already-absent', + branch: 'rollback/v1.2.0-remove-first-release', + pullRequest: 9, + commit: 'd'.repeat(40), + }, + completedAt: new Date(Date.parse(timestamp) + 2 * 60_000).toISOString(), + }, + }))).toThrow(/first-release rollback repository evidence is invalid/); + + const legacyLedger = path.join(root, 'legacy.jsonl'); + appendEvent(legacyLedger, stable('train_started', 0, { + payload: {channels: ['npm']}, + })); + appendEvent(legacyLedger, stable('rollback_started', 1, { + payload: { + knownGoodVersion: '1.1.9', + knownGoodCommit: 'b'.repeat(40), + forwardFixVersion: '1.2.1', + forwardFixBranch: 'release/forward-fix-v1.2.1-from-v1.1.9', + }, + })); + expect(materializeReleaseState(readEvents(legacyLedger)).recovery.mode) + .toBe('known-good'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('runs first-release withdrawal through the CLI idempotently', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'guardscan-withdrawal-cli-')); + const ledger = path.join(root, 'ledger.jsonl'); + const authorityFile = path.join(root, 'authority.json'); + const repositoryRoot = path.resolve(__dirname, '../../..'); + const packageRoot = path.join(repositoryRoot, 'cli'); + const actualCommit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repositoryRoot, + encoding: 'utf8', + }).trim(); + const cliSource = { + version: '1.1.0', + tag: 'v1.1.0', + commit: actualCommit, + }; + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + appendEvent(ledger, { + ...cliSource, + timestamp, + type: 'train_started', + idempotencyKey: 'train:v1.1.0', + payload: {channels: ['npm', 'homebrew']}, + }); + fs.writeFileSync(authorityFile, `${JSON.stringify({ + schemaVersion: 'guardscan.first-release-withdrawal-authority.v1', + verified: true, + defectiveVersion: '1.1.0', + defectiveTag: 'v1.1.0', + defectiveCommit: actualCommit, + ledgerCommit: 'c'.repeat(40), + priorCompleteStableVersions: [], + })}\n`); + const args = [ + 'rollback', + '--package-root', packageRoot, + '--repository-root', repositoryRoot, + '--commit', actualCommit, + '--tag', 'v1.1.0', + '--ledger', ledger, + '--timestamp', '2026-07-20T00:01:00.000Z', + '--idempotency-key', 'rollback:1.1.0', + '--first-release-withdrawal', + '--first-release-authority', authorityFile, + ]; + await main(args); + const first = readEvents(ledger); + await main(args); + expect(readEvents(ledger)).toEqual(first); + expect(materializeReleaseState(first)).toMatchObject({ + recovery: { + status: 'started', + mode: 'first-release-withdrawal', + requiredNextVersion: '1.1.1', + }, + incidents: { + 'first-release-withdrawal-v1.1.0': { + kind: 'recovery', + status: 'open', + }, + }, + }); + } finally { + stdout.mockRestore(); + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('models moderated rejection, correction, and resubmission without losing provider identity', () => { + const wingetEvidence = ( + state: string, + pullRequest: number, + providerCommit: string, + digestCharacter: string, + reason?: string + ) => { + const files = { + 'NaumanTanwir.GuardScan.installer.yaml': digestCharacter.repeat(64), + 'NaumanTanwir.GuardScan.locale.en-US.yaml': digestCharacter.repeat(64), + 'NaumanTanwir.GuardScan.yaml': digestCharacter.repeat(64), + }; + const digestInput = Object.keys(files).sort() + .map(name => `${name}\0${files[name as keyof typeof files]}\n`).join(''); + const remoteDigest = crypto.createHash('sha256').update(digestInput).digest('hex'); + const providerPath = `manifests/n/NaumanTanwir/GuardScan/${source.version}`; + return { + schemaVersion: 'guardscan.moderated-submission.v1', + channel: 'winget', + version: source.version, + tag: source.tag, + state, + packageIdentity: `NaumanTanwir.GuardScan@${source.version}`, + remoteIdentity: `github:microsoft/winget-pkgs/pull/${pullRequest}@${providerCommit}#${providerPath}`, + remoteDigest, + files, + provider: { + repository: 'microsoft/winget-pkgs', + path: providerPath, + pullRequest, + commit: providerCommit, + publicBytesVerified: false, + pendingStateQuery: 'digest-bound open pull request, then protected release ledger', + }, + ...(reason ? {reason} : {}), + }; + }; + const first = eventInput('train_started', 0, { + payload: {channels: ['winget']}, + }); + const submittedEvidence = wingetEvidence('submitted', 42, 'b'.repeat(40), 'c'); + const submitted = eventInput('channel_submitted', 1, { + channel: 'winget', + payload: { + artifactIds: ['standalone:windows-x64'], + remoteIdentity: submittedEvidence.remoteIdentity, + remoteDigest: submittedEvidence.remoteDigest, + submission: submittedEvidence, + }, + }); + const rejectionReason = 'provider pull request closed without merge'; + const rejectedEvidence = { + ...submittedEvidence, + state: 'rejected', + reason: rejectionReason, + }; + const rejected = eventInput('channel_rejected', 2, { + channel: 'winget', + payload: { + artifactIds: ['standalone:windows-x64'], + remoteIdentity: submitted.payload.remoteIdentity, + remoteDigest: submitted.payload.remoteDigest, + reason: rejectionReason, + submission: rejectedEvidence, + }, + }); + const correctedEvidence = wingetEvidence('corrected', 42, 'd'.repeat(40), 'e'); + const corrected = eventInput('channel_corrected', 3, { + channel: 'winget', + payload: { + artifactIds: ['standalone:windows-x64'], + remoteIdentity: correctedEvidence.remoteIdentity, + remoteDigest: correctedEvidence.remoteDigest, + submission: correctedEvidence, + }, + }); + const resubmittedEvidence = wingetEvidence('resubmitted', 43, 'f'.repeat(40), 'e'); + const resubmitted = eventInput('channel_resubmitted', 4, { + channel: 'winget', + payload: { + artifactIds: ['standalone:windows-x64'], + remoteIdentity: resubmittedEvidence.remoteIdentity, + remoteDigest: resubmittedEvidence.remoteDigest, + submission: resubmittedEvidence, + }, + }); + const events = [createEvent(first)]; + for (const input of [submitted, rejected, corrected, resubmitted]) { + events.push(createEvent(input, events.at(-1))); + } + const state = materializeReleaseState(events); + expect(state.channels.winget).toMatchObject({ + status: 'resubmitted', + remoteIdentity: resubmitted.payload.remoteIdentity, + remoteDigest: resubmitted.payload.remoteDigest, + submission: resubmittedEvidence, + }); + expect(reconcileRelease(state)).toMatchObject({ + complete: false, + actions: [{ + channel: 'winget', + required: true, + currentStatus: 'resubmitted', + action: 'poll-resubmission', + }], + }); + expect(() => materializeReleaseState([events[0], events[1], createEvent( + eventInput('channel_resubmitted', 2, { + channel: 'winget', + payload: { + artifactIds: ['standalone:windows-x64'], + remoteIdentity: resubmittedEvidence.remoteIdentity, + remoteDigest: resubmittedEvidence.remoteDigest, + submission: resubmittedEvidence, + }, + }), + events[1] + )])).toThrow(/cannot move winget from submitted to resubmitted/); + }); }); describe('promotion policy and remote idempotency', () => { @@ -396,8 +1065,11 @@ describe('promotion policy and remote idempotency', () => { publishedAt: timestamp, sourcePr: 42, sourcePrHead: commit, + sourcePrBase: 'd'.repeat(40), + sourcePrTree: 'e'.repeat(40), }, currentSourcePrHead: commit, + currentSourcePrBase: 'd'.repeat(40), evaluatedAt: '2026-07-21T00:30:00.000Z', requiredChannels: channels, canaries, @@ -413,11 +1085,16 @@ describe('promotion policy and remote idempotency', () => { }); const changed = decisionInput(); changed.currentSourcePrHead = 'c'.repeat(40); + changed.currentSourcePrBase = 'f'.repeat(40); changed.incidents = [{incidentId: 'incident-1', kind: 'integrity', status: 'open'}]; expect(createPromotionDecision(changed)).toMatchObject({ eligible: false, result: 'denied', - reasons: expect.arrayContaining(['source_pr_head_changed', 'active_release_incident']), + reasons: expect.arrayContaining([ + 'source_pr_head_changed', + 'source_pr_base_changed', + 'active_release_incident', + ]), }); }); diff --git a/cli/__tests__/scripts/release-workflows.test.ts b/cli/__tests__/scripts/release-workflows.test.ts index bbe0e5f..f4290e6 100644 --- a/cli/__tests__/scripts/release-workflows.test.ts +++ b/cli/__tests__/scripts/release-workflows.test.ts @@ -11,6 +11,7 @@ const catalogWorkflow = path.join( const releaseWorkflows = [ 'release-build.yml', 'release-canary.yml', + 'release-first-withdrawal.yml', 'release-please.yml', 'release-publish.yml', 'release-train.yml', @@ -59,6 +60,11 @@ describe('zero-touch release workflow contracts', () => { } }); + it('passes an explicit package manager to the reusable release quality gate', () => { + expect(workflowSource('release-build.yml')) + .toContain('npm run test:package-manager -- --manager npm'); + }); + it('keeps workflow heredoc terminators at shell column zero', () => { const sources = [ ...['ci.yml', ...releaseWorkflows].map(workflowSource), @@ -133,13 +139,16 @@ describe('zero-touch release workflow contracts', () => { it('fails closed and persists an idempotent repository-side rollback recovery', () => { const train = workflowSource('release-train.yml'); + const publish = workflowSource('release-publish.yml'); expect(train).toContain('repositories: GuardScan,homebrew-tap'); - expect(train).toContain("schemaVersion: 'guardscan.rollback-plan.v1'"); + expect(train).toContain("plan.schemaVersion !== 'guardscan.rollback-plan.v1'"); expect(train).toContain('verified known-good release is required'); expect(train).toContain('known-good release ledger is incomplete'); expect(train).toContain('known-good manifest digest does not match its protected ledger'); - expect(train).toContain('release/forward-fix-v${FORWARD_FIX_VERSION}-from-v${KNOWN_GOOD_VERSION}'); - expect(train).toContain('Rollback GuardScan v${{ inputs.version }} catalog to v${{ inputs.known_good }}'); + expect(train).toContain('DEFECTIVE_VERSION: ${{ inputs.version }}'); + expect(train).toContain('KNOWN_GOOD_VERSION: ${{ inputs.known_good }}'); + expect(train).toContain('require("./rollback-plan.json").forwardFixBranch'); + expect(train).toContain('Rollback GuardScan v$DEFECTIVE_VERSION catalog to v$KNOWN_GOOD_VERSION'); expect(train).toContain('gh pr create "${FORWARD_FIX_PR_ARGS[@]}"'); expect(train).toContain('gh pr merge --repo ntanwir10/homebrew-tap'); expect(train).toContain("event.type === 'action_required'"); @@ -147,10 +156,79 @@ describe('zero-touch release workflow contracts', () => { expect(train).toContain('rollback-plan-v${{ inputs.version }}'); expect(train).toContain('rollback-plan.json'); expect(train).toContain('rollback-evidence.json'); + expect(train).toContain('ROLLBACK_KEY="rollback:$DEFECTIVE_VERSION"'); + expect(train).not.toContain('ROLLBACK_KEY="rollback:$DEFECTIVE_VERSION:$GITHUB_RUN_ID"'); + expect(train).toContain('test "$(git rev-list -n 1 "v$KNOWN_GOOD_VERSION")" = "$KNOWN_GOOD_COMMIT"'); + expect(train).toContain('git worktree add forward-fix-source "v$KNOWN_GOOD_VERSION"'); + expect(train).toContain('FORWARD_FIX_CREATED_AT="$(git show -s --format=%cI "$KNOWN_GOOD_COMMIT")"'); + expect(train).toContain('merged forward-fix pull request does not match the deterministic trusted tree'); + expect(train).toContain('--force-with-lease="refs/heads/$FORWARD_FIX_BRANCH:$FORWARD_FIX_REMOTE_HEAD"'); + expect(train).toContain('forward-fix branch has a closed-unmerged pull request'); + expect(train).toContain('forward-fix pull request is not bound to the pushed head'); + expect(train).toContain('const expected = [\'cli/CHANGELOG.md\', \'cli/package-lock.json\', \'cli/package.json\'];'); + expect(train).toContain('git -C catalog-rollback fetch origin main'); + expect(train).toContain('git -C catalog-rollback switch -c "$CATALOG_BRANCH" "$CATALOG_BASE"'); + expect(train).toContain('--force-with-lease="refs/heads/$CATALOG_BRANCH:$CATALOG_REMOTE_HEAD"'); + expect(train).toContain('catalog rollback branch contains unreviewed paths'); + expect(train).toContain("const allowed = ['Formula/guardscan.rb', 'bucket/guardscan.json', 'channel-lock.json'];"); + expect(train).toContain('catalog already-restored path has no bound merged pull request'); + expect(train).toContain('catalog rollback pull request is not bound to the pushed head'); + expect(train).toContain('gh pr merge --repo ntanwir10/homebrew-tap "$CATALOG_PR" --squash'); + expect(train).not.toContain('gh pr merge --repo ntanwir10/homebrew-tap "$CATALOG_PR" --auto --squash'); + expect(train).toContain('test "$CATALOG_PR_STATE" = MERGED'); + expect(train).toContain('catalog rollback has no actual merge commit'); + expect(train).toContain('git -C catalog-rollback rev-parse "$CATALOG_MERGE_SHA:$CATALOG_PATH"'); + expect(train).toContain('echo "commit=$CATALOG_MERGE_SHA" >> "$GITHUB_OUTPUT"'); + expect(train).toContain('EXPECTED_BASE="$(node -p \'require("./promotion-decision.json").stable.sourcePrBase\')"'); + expect(train).toContain('CURRENT_BASE="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$REQUEST_RELEASE_PR" --jq .base.sha)"'); + expect(train).toContain('--squash --match-head-commit "$EXPECTED_HEAD"'); + expect(train).not.toContain('--auto --squash --match-head-commit "$EXPECTED_HEAD"'); + expect(train).toContain('const canonicalEntries = entries => entries.sort(([left], [right])'); + expect(train).toContain('npm: Object.fromEntries(canonicalEntries('); + expect(train).toContain('pypi: Object.fromEntries(canonicalEntries('); + expect(publish).toMatch( + /channel_accepted', 'channel_rejected', 'channel_corrected', 'channel_resubmitted'/ + ); + expect(publish.match(/channel_accepted', 'channel_rejected', 'channel_corrected', 'channel_resubmitted'/g)) + .toHaveLength(2); expect(train).not.toContain('NPM_TOKEN'); expect(train).not.toContain('PYPI_TOKEN'); }); + it('withdraws the first stable release only with protected-ledger authority', () => { + const train = workflowSource('release-train.yml'); + const withdrawal = workflowSource('release-first-withdrawal.yml'); + expect(train).toContain("inputs.known_good == ''"); + expect(train).toContain("inputs.known_good != ''"); + expect(train).toContain('uses: ./.github/workflows/release-first-withdrawal.yml'); + expect(withdrawal).toContain('assertFirstReleaseWithdrawal'); + expect(withdrawal).toContain('first-release-authority.json'); + expect(withdrawal).toContain('--first-release-withdrawal'); + expect(withdrawal).toContain('--first-release-authority first-release-authority.json'); + expect(withdrawal).toContain("plan.mode !== 'first-release-withdrawal'"); + expect(withdrawal).toContain("event.payload.kind === 'recovery'"); + expect(withdrawal).toContain('prepareFirstReleaseCatalogWithdrawal'); + expect(withdrawal).toContain('PUBLISH_BRANCH="guardscan/v$DEFECTIVE_VERSION"'); + expect(withdrawal).toContain('gh pr close --repo ntanwir10/homebrew-tap'); + expect(withdrawal).toContain('completed withdrawal was republished at $CATALOG_PATH'); + expect(withdrawal).toContain("'D\\tFormula/guardscan.rb'"); + expect(withdrawal).toContain("'D\\tbucket/guardscan.json'"); + expect(withdrawal).toContain("'D\\tchannel-lock.json'"); + expect(withdrawal).toContain('catalog withdrawal left $CATALOG_PATH published'); + expect(withdrawal).toContain("github: 'superseded'"); + expect(withdrawal).toContain("homebrew: 'withdrawn'"); + expect(withdrawal).toContain("scoop: 'withdrawn'"); + expect(withdrawal).toContain('externalActionsPending'); + expect(withdrawal).toContain("'provider-actions-pending'"); + expect(withdrawal).not.toContain('git worktree add forward-fix-source'); + expect(withdrawal).not.toContain('known-good-release'); + expect(withdrawal).not.toContain('gh release delete'); + expect(withdrawal).not.toContain('npm unpublish'); + expect(train).toContain('sourcePrBase: process.env.SOURCE_PR_BASE'); + expect(train).toContain('sourcePrTree: process.env.SOURCE_PR_TREE'); + expect(train).not.toMatch(/Persist refetched catalog publication evidence\n\s+if:[^\n]+\n\s+env:\s*\n/); + }); + it('uses default-branch canary tooling while every matrix entry verifies its own version', () => { const canary = workflowSource('release-canary.yml'); expect(canary).not.toContain('implementation_ref'); @@ -220,10 +298,13 @@ describe('zero-touch release workflow contracts', () => { expect(train).toContain("pr.base?.ref !== 'main'"); expect(train).toContain('pr.head?.repo?.full_name?.toLowerCase()'); expect(train).toContain('process.env.GITHUB_REPOSITORY.toLowerCase()'); - expect(train).toContain('gh pr ready "${{ inputs.release_pr }}"'); - expect(train.indexOf('gh pr ready "${{ inputs.release_pr }}"')) + expect(train).toContain('REQUEST_RELEASE_PR: ${{ inputs.release_pr }}'); + expect(train).toContain('EXPECTED_HEAD: ${{ steps.source.outputs.head_sha }}'); + expect(train).toContain('gh pr ready "$REQUEST_RELEASE_PR"'); + expect(train.indexOf('gh pr ready "$REQUEST_RELEASE_PR"')) .toBeLessThan(train.indexOf('Derive bot-owned RC commit from exact stable PR head')); - expect(train).toContain('--match-head-commit "${{ steps.source.outputs.head_sha }}"'); + expect(train).toContain('--match-head-commit "$EXPECTED_HEAD"'); + expect(train).not.toContain('gh pr ready "${{ inputs.release_pr }}"'); }); it('builds signed artifacts and publishes through isolated provider environments', () => { diff --git a/cli/package-lock.json b/cli/package-lock.json index 522aa0e..6b971d1 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -19,7 +19,7 @@ "fast-glob": "^3.3.2", "ignore": "^5.3.0", "inquirer": "^8.2.5", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.1", "marked": "^11.0.0", "openai": "^6.9.0", "ora": "^5.4.1", @@ -1836,9 +1836,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -2375,16 +2375,16 @@ } }, "node_modules/@stryker-mutator/core/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@stryker-mutator/core/node_modules/chalk": { @@ -3007,16 +3007,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -3453,9 +3453,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -4635,9 +4635,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -6064,9 +6064,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", diff --git a/cli/package.json b/cli/package.json index f12c02b..7eaa5ee 100644 --- a/cli/package.json +++ b/cli/package.json @@ -89,7 +89,7 @@ "fast-glob": "^3.3.2", "ignore": "^5.3.0", "inquirer": "^8.2.5", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.1", "marked": "^11.0.0", "openai": "^6.9.0", "ora": "^5.4.1", @@ -127,7 +127,7 @@ "node": ">=22.0.0" }, "overrides": { - "brace-expansion@<1.1.16": "1.1.16", + "brace-expansion@<1.1.18": "1.1.18", "qs": "6.15.3" } } diff --git a/cli/schemas/guardscan.promotion-decision.v1.schema.json b/cli/schemas/guardscan.promotion-decision.v1.schema.json index 6f80e36..59fd46b 100644 --- a/cli/schemas/guardscan.promotion-decision.v1.schema.json +++ b/cli/schemas/guardscan.promotion-decision.v1.schema.json @@ -62,7 +62,9 @@ "publishedAt", "eligibleAt", "sourcePr", - "sourcePrHead" + "sourcePrHead", + "sourcePrBase", + "sourcePrTree" ], "properties": { "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+-rc\\.[0-9]+$" }, @@ -72,18 +74,29 @@ "publishedAt": { "type": "string", "format": "date-time" }, "eligibleAt": { "type": "string", "format": "date-time" }, "sourcePr": { "type": "integer", "minimum": 1 }, - "sourcePrHead": { "$ref": "#/definitions/commit" } + "sourcePrHead": { "$ref": "#/definitions/commit" }, + "sourcePrBase": { "$ref": "#/definitions/commit" }, + "sourcePrTree": { "$ref": "#/definitions/commit" } } }, "stable": { "type": "object", "additionalProperties": false, - "required": ["version", "tag", "sourcePr", "sourcePrHead"], + "required": [ + "version", + "tag", + "sourcePr", + "sourcePrHead", + "sourcePrBase", + "sourcePrTree" + ], "properties": { "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, "tag": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" }, "sourcePr": { "type": "integer", "minimum": 1 }, - "sourcePrHead": { "$ref": "#/definitions/commit" } + "sourcePrHead": { "$ref": "#/definitions/commit" }, + "sourcePrBase": { "$ref": "#/definitions/commit" }, + "sourcePrTree": { "$ref": "#/definitions/commit" } } }, "policy": { diff --git a/cli/schemas/guardscan.release-event.v1.schema.json b/cli/schemas/guardscan.release-event.v1.schema.json index 0b21c14..423da03 100644 --- a/cli/schemas/guardscan.release-event.v1.schema.json +++ b/cli/schemas/guardscan.release-event.v1.schema.json @@ -41,9 +41,13 @@ "channel_accepted", "channel_verified", "channel_failed", + "channel_rejected", + "channel_corrected", + "channel_resubmitted", "canary_recorded", "promotion_decided", "rollback_started", + "rollback_repository_completed", "action_required", "withdrawn", "superseded", @@ -69,5 +73,195 @@ "idempotencyKey": { "type": "string", "minLength": 1, "maxLength": 200 }, "payload": { "type": "object", "maxProperties": 30 }, "eventHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + }, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "train_started" } }, + "required": ["type"] + }, + "then": { + "properties": { + "payload": { + "oneOf": [ + { "$ref": "#/definitions/legacyTrainStart" }, + { "$ref": "#/definitions/sourceBoundTrainStart" } + ] + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "rollback_started" } }, + "required": ["type"] + }, + "then": { + "properties": { + "payload": { + "oneOf": [ + { "$ref": "#/definitions/knownGoodRollbackStart" }, + { "$ref": "#/definitions/legacyKnownGoodRollbackStart" }, + { "$ref": "#/definitions/firstReleaseWithdrawalStart" } + ] + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "rollback_repository_completed" } }, + "required": ["type"] + }, + "then": { + "properties": { + "payload": { + "oneOf": [ + { "$ref": "#/definitions/knownGoodRepositoryEvidence" }, + { "$ref": "#/definitions/firstReleaseRepositoryEvidence" } + ] + } + } + } + } + ], + "definitions": { + "releaseChannels": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "npm", "pnpm", "yarn", "bun", "github", "homebrew", + "homebrew-core", "scoop", "winget", "chocolatey", "pypi" + ] + } + }, + "legacyTrainStart": { + "type": "object", + "additionalProperties": false, + "required": ["channels"], + "properties": { + "channels": { "$ref": "#/definitions/releaseChannels" } + } + }, + "sourceBoundTrainStart": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile", "releasePr", "sourcePrHead", "sourcePrBase", "sourcePrTree", "channels" + ], + "properties": { + "profile": { "const": "full" }, + "releasePr": { "type": "integer", "minimum": 1 }, + "sourcePrHead": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "sourcePrBase": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "sourcePrTree": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "channels": { "$ref": "#/definitions/releaseChannels" } + } + }, + "knownGoodRollbackStart": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "knownGoodVersion", + "knownGoodCommit", + "forwardFixVersion", + "forwardFixBranch" + ], + "properties": { + "mode": { "const": "known-good" }, + "knownGoodVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "knownGoodCommit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "forwardFixVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "forwardFixBranch": { "type": "string", "minLength": 1, "maxLength": 200 } + } + }, + "legacyKnownGoodRollbackStart": { + "type": "object", + "additionalProperties": false, + "required": [ + "knownGoodVersion", + "knownGoodCommit", + "forwardFixVersion", + "forwardFixBranch" + ], + "properties": { + "knownGoodVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "knownGoodCommit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "forwardFixVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "forwardFixBranch": { "type": "string", "minLength": 1, "maxLength": 200 } + } + }, + "firstReleaseWithdrawalStart": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "requiredNextVersion"], + "properties": { + "mode": { "const": "first-release-withdrawal" }, + "requiredNextVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } + } + }, + "knownGoodRepositoryEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "mode", + "defectiveVersion", + "knownGoodVersion", + "forwardFix", + "catalog", + "completedAt" + ], + "properties": { + "schemaVersion": { "const": "guardscan.rollback-repository-evidence.v1" }, + "mode": { "const": "known-good" }, + "defectiveVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "knownGoodVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "forwardFix": { "type": "object" }, + "catalog": { "type": "object" }, + "completedAt": { "type": "string", "format": "date-time" } + } + }, + "firstReleaseRepositoryEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "mode", + "defectiveVersion", + "requiredNextVersion", + "externalActionsPending", + "catalog", + "completedAt" + ], + "properties": { + "schemaVersion": { "const": "guardscan.rollback-repository-evidence.v1" }, + "mode": { "const": "first-release-withdrawal" }, + "defectiveVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "requiredNextVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "externalActionsPending": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["npm", "pypi", "homebrew-core", "winget", "chocolatey"] + } + }, + "catalog": { + "type": "object", + "additionalProperties": false, + "required": ["state", "branch", "pullRequest", "commit"], + "properties": { + "state": { "enum": ["already-absent", "removed"] }, + "branch": { "type": "string", "minLength": 1, "maxLength": 200 }, + "pullRequest": { "type": "integer", "minimum": 0 }, + "commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" } + } + }, + "completedAt": { "type": "string", "format": "date-time" } + } + } } } diff --git a/cli/schemas/guardscan.release-state.v2.schema.json b/cli/schemas/guardscan.release-state.v2.schema.json index a85bd86..13256ec 100644 --- a/cli/schemas/guardscan.release-state.v2.schema.json +++ b/cli/schemas/guardscan.release-state.v2.schema.json @@ -28,7 +28,20 @@ "channels": { "type": "object", "minProperties": 1, - "additionalProperties": { "$ref": "#/definitions/channel" } + "additionalProperties": false, + "properties": { + "npm": { "$ref": "#/definitions/primaryPublicationChannel" }, + "pnpm": { "$ref": "#/definitions/channel" }, + "yarn": { "$ref": "#/definitions/channel" }, + "bun": { "$ref": "#/definitions/channel" }, + "github": { "$ref": "#/definitions/primaryPublicationChannel" }, + "homebrew": { "$ref": "#/definitions/channel" }, + "homebrew-core": { "$ref": "#/definitions/channel" }, + "scoop": { "$ref": "#/definitions/channel" }, + "winget": { "$ref": "#/definitions/channel" }, + "chocolatey": { "$ref": "#/definitions/channel" }, + "pypi": { "$ref": "#/definitions/primaryPublicationChannel" } + } }, "canaries": { "type": "object", @@ -45,9 +58,31 @@ "type": "array", "items": { "$ref": "#/definitions/actionRequired" } }, - "promotion": { "type": "object" } + "promotion": { "type": "object" }, + "recovery": { "$ref": "#/definitions/recovery" } }, "definitions": { + "primaryPublicationChannel": { + "allOf": [ + { "$ref": "#/definitions/channel" }, + { + "if": { + "type": "object", + "properties": { + "status": { "enum": ["published", "verified", "superseded"] } + }, + "required": ["status"] + }, + "then": { + "type": "object", + "properties": { + "publication": { "$ref": "#/definitions/publicationEvidence" } + }, + "required": ["publication"] + } + } + ] + }, "channel": { "type": "object", "additionalProperties": false, @@ -61,6 +96,9 @@ "accepted", "verified", "failed", + "rejected", + "corrected", + "resubmitted", "withdrawn", "superseded" ] @@ -74,9 +112,41 @@ "remoteIdentity": { "type": "string", "minLength": 1, "maxLength": 500 }, "remoteDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "catalog": { "$ref": "#/definitions/catalogEvidence" }, + "publication": { "$ref": "#/definitions/publicationEvidence" }, + "submission": { "type": "object" }, "error": { "type": "string", "minLength": 1, "maxLength": 2000 } } }, + "publicationEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "channel", + "version", + "tag", + "remoteIdentity", + "aggregateSha256", + "files" + ], + "properties": { + "schemaVersion": { "const": "guardscan.provider-publication.v1" }, + "channel": { "enum": ["github", "npm", "pypi"] }, + "version": { "type": "string", "minLength": 1, "maxLength": 100 }, + "tag": { "type": "string", "minLength": 2, "maxLength": 101 }, + "remoteIdentity": { "type": "string", "minLength": 1, "maxLength": 500 }, + "aggregateSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "files": { + "type": "object", + "minProperties": 1, + "maxProperties": 100, + "propertyNames": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+~-]{0,199}$" + }, + "additionalProperties": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + } + }, "catalogEvidence": { "type": "object", "additionalProperties": false, @@ -116,7 +186,7 @@ "additionalProperties": false, "required": ["kind", "status", "openedAt", "summary"], "properties": { - "kind": { "enum": ["integrity", "security", "availability"] }, + "kind": { "enum": ["integrity", "security", "availability", "recovery"] }, "status": { "enum": ["open", "resolved"] }, "openedAt": { "type": "string", "format": "date-time" }, "resolvedAt": { "type": "string", "format": "date-time" }, @@ -134,6 +204,54 @@ "reason": { "type": "string", "minLength": 1, "maxLength": 2000 }, "requestedAt": { "type": "string", "format": "date-time" } } + }, + "recovery": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "startedAt", + "mode", + "knownGoodVersion", + "knownGoodCommit", + "forwardFixVersion", + "forwardFixBranch" + ], + "properties": { + "status": { "enum": ["started", "repository-completed"] }, + "startedAt": { "type": "string", "format": "date-time" }, + "repositoryCompletedAt": { "type": "string", "format": "date-time" }, + "mode": { "const": "known-good" }, + "knownGoodVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "knownGoodCommit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "forwardFixVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "forwardFixBranch": { "type": "string", "minLength": 1, "maxLength": 200 }, + "evidence": { "type": "object" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "startedAt", "mode", "requiredNextVersion"], + "properties": { + "status": { "enum": ["started", "repository-completed", "provider-actions-pending"] }, + "startedAt": { "type": "string", "format": "date-time" }, + "repositoryCompletedAt": { "type": "string", "format": "date-time" }, + "mode": { "const": "first-release-withdrawal" }, + "requiredNextVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "externalActionsPending": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["npm", "pypi", "homebrew-core", "winget", "chocolatey"] + } + }, + "evidence": { "type": "object" } + } + } + ] } } } diff --git a/cli/scripts/eslint-baseline.json b/cli/scripts/eslint-baseline.json index 1e0b862..5aab134 100644 --- a/cli/scripts/eslint-baseline.json +++ b/cli/scripts/eslint-baseline.json @@ -151,10 +151,6 @@ "errors": 11, "warnings": 0 }, - "src/core/cost-guard.ts": { - "errors": 1, - "warnings": 1 - }, "src/core/dependency-scanner.ts": { "errors": 6, "warnings": 0 diff --git a/cli/scripts/release/candidate.js b/cli/scripts/release/candidate.js index 7bc1a76..653592c 100644 --- a/cli/scripts/release/candidate.js +++ b/cli/scripts/release/candidate.js @@ -22,7 +22,15 @@ function writeJson(file, document) { fs.writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); } -function createReleaseCandidate(source, candidateVersion, sourcePr, sourcePrHead, timestamp) { +function createReleaseCandidate( + source, + candidateVersion, + sourcePr, + sourcePrHead, + sourcePrBase, + sourcePrTree, + timestamp +) { assertCandidateVersion(source.version, candidateVersion); if (!Number.isSafeInteger(Number(sourcePr)) || Number(sourcePr) < 1) { throw new Error('release candidate source PR is invalid'); @@ -30,6 +38,12 @@ function createReleaseCandidate(source, candidateVersion, sourcePr, sourcePrHead if (!/^[a-f0-9]{40}$/.test(sourcePrHead || '')) { throw new Error('release candidate source PR head is invalid'); } + if (!/^[a-f0-9]{40}$/.test(sourcePrBase || '')) { + throw new Error('release candidate source PR base is invalid'); + } + if (!/^[a-f0-9]{40}$/.test(sourcePrTree || '')) { + throw new Error('release candidate source PR tree is invalid'); + } const date = new Date(timestamp); if (!Number.isFinite(date.getTime()) || date.toISOString() !== timestamp) { throw new Error('release candidate timestamp must be canonical'); @@ -62,6 +76,8 @@ function createReleaseCandidate(source, candidateVersion, sourcePr, sourcePrHead candidateVersion, sourcePr: Number(sourcePr), sourcePrHead, + sourcePrBase, + sourcePrTree, createdAt: timestamp, }; writeJson(path.join(source.repositoryRoot, '.release-candidate.json'), metadata); diff --git a/cli/scripts/release/events.js b/cli/scripts/release/events.js index bd40ad5..46772b3 100644 --- a/cli/scripts/release/events.js +++ b/cli/scripts/release/events.js @@ -4,10 +4,16 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const {CHANNELS, readBounded} = require('./lib'); +const {createPublicationEvidence} = require('./publication-evidence'); const EVENT_SCHEMA = 'guardscan.release-event.v1'; const MAX_EVENT_BYTES = 64 * 1024; const CATALOG_IDENTITY_PATTERN = /^github:ntanwir10\/homebrew-tap@[a-f0-9]{40}#(?:Formula\/guardscan\.rb|bucket\/guardscan\.json)$/; +const PRIMARY_PUBLICATION_CHANNELS = new Set(['github', 'npm', 'pypi']); +const MODERATED_CHANNELS = new Set(['winget', 'chocolatey']); +const EXTERNAL_WITHDRAWAL_CHANNELS = new Set([ + 'npm', 'pypi', 'homebrew-core', 'winget', 'chocolatey', +]); const EVENT_TYPES = Object.freeze([ 'train_started', 'artifact_built', @@ -18,9 +24,13 @@ const EVENT_TYPES = Object.freeze([ 'channel_accepted', 'channel_verified', 'channel_failed', + 'channel_rejected', + 'channel_corrected', + 'channel_resubmitted', 'canary_recorded', 'promotion_decided', 'rollback_started', + 'rollback_repository_completed', 'action_required', 'withdrawn', 'superseded', @@ -33,6 +43,9 @@ const CHANNEL_EVENT_STATUS = Object.freeze({ channel_accepted: 'accepted', channel_verified: 'verified', channel_failed: 'failed', + channel_rejected: 'rejected', + channel_corrected: 'corrected', + channel_resubmitted: 'resubmitted', withdrawn: 'withdrawn', superseded: 'superseded', }); @@ -78,6 +91,7 @@ function assertIdentity(document, expected, label) { function validateCatalogEvidence(event) { const evidence = event.payload?.catalog; if (evidence === undefined) return; + if (event.type === 'rollback_repository_completed') return; if (!['homebrew', 'scoop'].includes(event.channel)) { throw new Error('catalog evidence is valid only for homebrew or scoop events'); } @@ -142,6 +156,303 @@ function validateActionRequired(event) { } } +function validatePublicationEvidence(event) { + const evidence = event.payload?.publication; + const required = event.type === 'channel_published' + && PRIMARY_PUBLICATION_CHANNELS.has(event.channel); + if (evidence === undefined) { + if (required) { + throw new Error('primary channel publication requires provider-bound file evidence'); + } + return; + } + if (!required) { + throw new Error('provider publication evidence is valid only for primary published channels'); + } + if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) { + throw new Error('provider publication evidence must be an object'); + } + const normalized = createPublicationEvidence(evidence); + if (canonicalJson(normalized) !== canonicalJson(evidence)) { + throw new Error('provider publication evidence is not canonical'); + } + if (evidence.channel !== event.channel + || evidence.version !== event.version + || evidence.tag !== event.tag + || evidence.remoteIdentity !== event.payload.remoteIdentity + || evidence.aggregateSha256 !== event.payload.remoteDigest) { + throw new Error('provider publication evidence does not match its release event'); + } +} + +function validateTrainStarted(event) { + if (event.type !== 'train_started') return; + const channels = event.payload.channels; + const supported = new Set(CHANNELS.map(channel => channel.id)); + if (!Array.isArray(channels) || channels.length < 1 + || new Set(channels).size !== channels.length + || channels.some(channel => typeof channel !== 'string' || !supported.has(channel))) { + throw new Error('release train channels must be a non-empty unique supported channel list'); + } + const sourceFields = ['profile', 'releasePr', 'sourcePrHead', 'sourcePrBase', 'sourcePrTree']; + if (sourceFields.some(field => event.payload[field] !== undefined)) { + const keys = Object.keys(event.payload).sort(); + const expected = ['channels', ...sourceFields].sort(); + if (keys.join('\n') !== expected.join('\n') + || event.payload.profile !== 'full' + || !Number.isSafeInteger(event.payload.releasePr) + || event.payload.releasePr < 1 + || ['sourcePrHead', 'sourcePrBase', 'sourcePrTree'].some(field => ( + !/^[a-f0-9]{40}$/.test(event.payload[field] || '') + ))) { + throw new Error('release train source provenance is incomplete or invalid'); + } + } +} + +function validateModerationEvent(event) { + const moderationTypes = new Set([ + 'channel_submitted', 'channel_accepted', 'channel_rejected', + 'channel_corrected', 'channel_resubmitted', + ]); + if (['channel_rejected', 'channel_corrected', 'channel_resubmitted'].includes(event.type) + && !MODERATED_CHANNELS.has(event.channel)) { + throw new Error(`${event.type} is valid only for moderated release channels`); + } + if (!moderationTypes.has(event.type) || !MODERATED_CHANNELS.has(event.channel)) return; + const evidence = event.payload.submission; + const requiresReason = event.type === 'channel_rejected'; + const payloadKeys = Object.keys(event.payload).sort(); + const expectedPayloadKeys = [ + 'artifactIds', 'remoteDigest', 'remoteIdentity', 'submission', + ...(requiresReason ? ['reason'] : []), + ].sort(); + if (payloadKeys.join('\n') !== expectedPayloadKeys.join('\n') + || !Array.isArray(event.payload.artifactIds) + || event.payload.artifactIds.length < 1 + || new Set(event.payload.artifactIds).size !== event.payload.artifactIds.length + || event.payload.artifactIds.some(id => ( + typeof id !== 'string' || id.length < 1 || id.length > 200 + )) + || typeof event.payload.remoteIdentity !== 'string' + || event.payload.remoteIdentity.length < 1 + || !/^[a-f0-9]{64}$/.test(event.payload.remoteDigest || '') + || !evidence + || typeof evidence !== 'object' + || Array.isArray(evidence)) { + throw new Error(`${event.type} requires canonical moderated provider evidence`); + } + const expectedState = { + channel_submitted: new Set(['submitted', 'pending', 'pending-ledger']), + channel_accepted: new Set(['accepted', 'public-exact']), + channel_rejected: new Set(['rejected']), + channel_corrected: new Set(['corrected']), + channel_resubmitted: new Set(['resubmitted']), + }[event.type]; + const commonKeys = [ + 'channel', 'packageIdentity', 'provider', 'remoteDigest', 'remoteIdentity', + 'schemaVersion', 'state', 'tag', 'version', + ]; + const expectedEvidenceKeys = [ + ...commonKeys, + ...(event.channel === 'winget' ? ['files'] : ['packageFilename']), + ...(requiresReason ? ['reason'] : []), + ].sort(); + if (Object.keys(evidence).sort().join('\n') !== expectedEvidenceKeys.join('\n') + || evidence.schemaVersion !== 'guardscan.moderated-submission.v1' + || evidence.channel !== event.channel + || evidence.version !== event.version + || evidence.tag !== event.tag + || !expectedState.has(evidence.state) + || evidence.remoteIdentity !== event.payload.remoteIdentity + || evidence.remoteDigest !== event.payload.remoteDigest + || typeof evidence.packageIdentity !== 'string' + || evidence.packageIdentity.length < 1 + || !evidence.provider + || typeof evidence.provider !== 'object' + || Array.isArray(evidence.provider) + || typeof evidence.provider.pendingStateQuery !== 'string' + || evidence.provider.pendingStateQuery.length < 1 + || evidence.provider.pendingStateQuery.length > 1000 + || (requiresReason && ( + typeof evidence.reason !== 'string' + || evidence.reason.length < 1 + || evidence.reason.length > 2000 + || event.payload.reason !== evidence.reason + ))) { + throw new Error(`${event.type} moderated provider evidence is not release-bound`); + } + if (event.channel === 'winget') { + const filenames = [ + 'NaumanTanwir.GuardScan.installer.yaml', + 'NaumanTanwir.GuardScan.locale.en-US.yaml', + 'NaumanTanwir.GuardScan.yaml', + ]; + const providerKeys = [ + 'commit', 'path', 'pendingStateQuery', 'publicBytesVerified', + 'pullRequest', 'repository', + ].sort(); + const fileNames = Object.keys(evidence.files || {}).sort(); + const digestInput = fileNames.map(name => `${name}\0${evidence.files[name]}\n`).join(''); + const publicExact = evidence.state === 'public-exact'; + const expectedIdentity = publicExact + ? `github:microsoft/winget-pkgs@${evidence.provider.commit}#${evidence.provider.path}` + : `github:microsoft/winget-pkgs/pull/${evidence.provider.pullRequest}@${evidence.provider.commit}#${evidence.provider.path}`; + if (Object.keys(evidence.provider).sort().join('\n') !== providerKeys.join('\n') + || fileNames.join('\n') !== filenames.join('\n') + || fileNames.some(name => !/^[a-f0-9]{64}$/.test(evidence.files[name] || '')) + || sha256(digestInput) !== evidence.remoteDigest + || evidence.packageIdentity !== `NaumanTanwir.GuardScan@${event.version}` + || evidence.provider.repository !== 'microsoft/winget-pkgs' + || evidence.provider.path !== `manifests/n/NaumanTanwir/GuardScan/${event.version}` + || !/^[a-f0-9]{40}$/.test(evidence.provider.commit || '') + || evidence.provider.publicBytesVerified !== publicExact + || (publicExact + ? evidence.provider.pullRequest !== null + : (!Number.isSafeInteger(evidence.provider.pullRequest) + || evidence.provider.pullRequest < 1)) + || evidence.remoteIdentity !== expectedIdentity) { + throw new Error(`${event.type} WinGet evidence is not artifact-bound`); + } + return; + } + const providerKeys = ['pendingStateQuery', 'publicBytesVerified', 'url'].sort(); + const publicExact = evidence.state === 'public-exact'; + const expectedUrl = `https://community.chocolatey.org/api/v2/package/guardscan/${event.version}`; + const expectedIdentity = publicExact ? expectedUrl : `chocolatey:guardscan@${event.version}`; + if (Object.keys(evidence.provider).sort().join('\n') !== providerKeys.join('\n') + || evidence.packageIdentity !== `guardscan@${event.version}` + || evidence.packageFilename !== `guardscan.${event.version}.nupkg` + || evidence.provider.url !== expectedUrl + || evidence.provider.publicBytesVerified !== publicExact + || evidence.remoteIdentity !== expectedIdentity) { + throw new Error(`${event.type} Chocolatey evidence is not artifact-bound`); + } +} + +function validateIncidentEvent(event) { + if (event.type === 'incident_opened') { + const keys = Object.keys(event.payload).sort(); + const expected = ['incidentId', 'kind', 'summary']; + if (keys.join('\n') !== expected.join('\n') + || typeof event.payload.incidentId !== 'string' + || event.payload.incidentId.length < 1 + || event.payload.incidentId.length > 200 + || !['integrity', 'security', 'availability', 'recovery'].includes(event.payload.kind) + || typeof event.payload.summary !== 'string' + || event.payload.summary.length < 1 + || event.payload.summary.length > 2000) { + throw new Error('incident_opened payload is invalid'); + } + } + if (event.type === 'incident_resolved') { + const keys = Object.keys(event.payload); + if (keys.length !== 1 || keys[0] !== 'incidentId' + || typeof event.payload.incidentId !== 'string' + || event.payload.incidentId.length < 1 + || event.payload.incidentId.length > 200) { + throw new Error('incident_resolved payload is invalid'); + } + } +} + +function validateRollbackStarted(event) { + if (event.type !== 'rollback_started') return; + const stable = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + const keys = Object.keys(event.payload).sort(); + if (event.payload.mode === 'known-good' || event.payload.mode === undefined) { + const expected = [ + 'forwardFixBranch', + 'forwardFixVersion', + 'knownGoodCommit', + 'knownGoodVersion', + ...(event.payload.mode === undefined ? [] : ['mode']), + ].sort(); + if (keys.join('\n') !== expected.join('\n') + || !stable.test(event.payload.knownGoodVersion || '') + || !stable.test(event.payload.forwardFixVersion || '') + || !/^[a-f0-9]{40}$/.test(event.payload.knownGoodCommit || '') + || typeof event.payload.forwardFixBranch !== 'string' + || event.payload.forwardFixBranch.length > 200) { + throw new Error('known-good rollback_started payload is invalid'); + } + return; + } + if (event.payload.mode === 'first-release-withdrawal') { + const expected = ['mode', 'requiredNextVersion']; + if (keys.join('\n') !== expected.join('\n') + || !stable.test(event.payload.requiredNextVersion || '')) { + throw new Error('first-release rollback_started payload is invalid'); + } + return; + } + throw new Error('rollback_started requires an explicit supported recovery mode'); +} + +function validateRollbackCompletion(event) { + if (event.type !== 'rollback_repository_completed') return; + const stable = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + const evidence = event.payload; + if (evidence.schemaVersion !== 'guardscan.rollback-repository-evidence.v1' + || !stable.test(evidence.defectiveVersion || '') + || evidence.defectiveVersion !== event.version) { + throw new Error('rollback repository evidence has invalid release identity'); + } + assertCanonicalTimestamp(evidence.completedAt, 'rollback repository completion'); + if (evidence.mode === 'known-good') { + const keys = Object.keys(evidence).sort(); + const expected = [ + 'catalog', + 'completedAt', + 'defectiveVersion', + 'forwardFix', + 'knownGoodVersion', + 'mode', + 'schemaVersion', + ].sort(); + if (keys.join('\n') !== expected.join('\n') + || !stable.test(evidence.knownGoodVersion || '') + || !evidence.forwardFix + || !evidence.catalog) { + throw new Error('known-good rollback repository evidence is invalid'); + } + return; + } + if (evidence.mode === 'first-release-withdrawal') { + const keys = Object.keys(evidence).sort(); + const expected = [ + 'catalog', + 'completedAt', + 'defectiveVersion', + 'externalActionsPending', + 'mode', + 'requiredNextVersion', + 'schemaVersion', + ].sort(); + const catalog = evidence.catalog; + const catalogKeys = Object.keys(catalog || {}).sort(); + const pending = evidence.externalActionsPending; + if (keys.join('\n') !== expected.join('\n') + || !stable.test(evidence.requiredNextVersion || '') + || !Array.isArray(pending) + || new Set(pending).size !== pending.length + || pending.join('\n') !== [...pending].sort().join('\n') + || pending.some(channel => !EXTERNAL_WITHDRAWAL_CHANNELS.has(channel)) + || !catalog + || catalogKeys.join('\n') !== ['branch', 'commit', 'pullRequest', 'state'].sort().join('\n') + || !['already-absent', 'removed'].includes(catalog.state) + || typeof catalog.branch !== 'string' + || !Number.isSafeInteger(catalog.pullRequest) + || (catalog.state === 'already-absent' && catalog.pullRequest !== 0) + || (catalog.state === 'removed' && catalog.pullRequest < 1) + || !/^[a-f0-9]{40}$/.test(catalog.commit || '')) { + throw new Error('first-release rollback repository evidence is invalid'); + } + return; + } + throw new Error('rollback repository evidence has an unsupported mode'); +} + function validateEvent(event, previous) { if (!event || typeof event !== 'object' || Array.isArray(event)) { throw new Error('release event must be an object'); @@ -169,6 +480,12 @@ function validateEvent(event, previous) { } validateCatalogEvidence(event); validateActionRequired(event); + validatePublicationEvidence(event); + validateModerationEvent(event); + validateIncidentEvent(event); + validateRollbackStarted(event); + validateRollbackCompletion(event); + validateTrainStarted(event); assertCanonicalTimestamp(event.timestamp, 'release event timestamp'); if (!/^[a-f0-9]{64}$/.test(event.eventHash || '') || event.eventHash !== eventDigest(event)) { @@ -324,6 +641,7 @@ function materializeReleaseState(events) { incidents: {}, actionRequired: [], promotion: undefined, + recovery: undefined, }; for (const event of events) { state.updatedAt = event.timestamp; @@ -336,6 +654,27 @@ function materializeReleaseState(events) { throw new Error(`${event.type} requires a channel present in the release train`); } const previous = state.channels[event.channel]; + const allowedTransitions = { + // Publication can be reconciled after a provider has already advanced. + // Permit the first provider-bound observation, but never reopen terminal states. + planned: [ + 'published', 'submitted', 'accepted', 'verified', 'failed', + 'rejected', 'corrected', 'resubmitted', 'withdrawn', + ], + published: ['verified', 'failed', 'withdrawn', 'superseded'], + submitted: ['published', 'accepted', 'rejected', 'failed', 'withdrawn', 'superseded'], + accepted: ['verified', 'failed', 'withdrawn', 'superseded'], + verified: ['failed', 'withdrawn', 'superseded'], + failed: ['accepted', 'verified', 'withdrawn', 'superseded'], + rejected: ['corrected', 'withdrawn', 'superseded'], + corrected: ['resubmitted', 'withdrawn', 'superseded'], + resubmitted: ['accepted', 'rejected', 'failed', 'withdrawn', 'superseded'], + withdrawn: [], + superseded: [], + }; + if (!allowedTransitions[previous.status]?.includes(status)) { + throw new Error(`${event.type} cannot move ${event.channel} from ${previous.status} to ${status}`); + } state.channels[event.channel] = { status, artifactIds: Array.isArray(event.payload.artifactIds) @@ -352,6 +691,12 @@ function materializeReleaseState(events) { ...(event.payload.catalog || previous.catalog ? {catalog: event.payload.catalog || previous.catalog} : {}), + ...(event.payload.publication || previous.publication + ? {publication: event.payload.publication || previous.publication} + : {}), + ...(event.payload.submission || previous.submission + ? {submission: event.payload.submission || previous.submission} + : {}), }; } if (event.type === 'canary_recorded') { @@ -381,6 +726,25 @@ function materializeReleaseState(events) { }; } if (event.type === 'promotion_decided') state.promotion = event.payload; + if (event.type === 'rollback_started') { + if (state.recovery) throw new Error('release recovery cannot be started more than once'); + state.recovery = { + status: 'started', + startedAt: event.timestamp, + ...(event.payload.mode ? event.payload : {mode: 'known-good', ...event.payload}), + }; + } + if (event.type === 'rollback_repository_completed') { + if (!state.recovery) throw new Error('rollback repository completion has no started recovery'); + const providerActionsPending = event.payload.mode === 'first-release-withdrawal' + && event.payload.externalActionsPending.length > 0; + state.recovery = { + ...state.recovery, + status: providerActionsPending ? 'provider-actions-pending' : 'repository-completed', + repositoryCompletedAt: event.timestamp, + evidence: event.payload, + }; + } if (event.type === 'action_required') { state.actionRequired.push({ channel: event.channel, @@ -391,8 +755,19 @@ function materializeReleaseState(events) { }); } } + if (state.recovery?.mode === 'first-release-withdrawal' + && Array.isArray(state.recovery.evidence?.externalActionsPending)) { + const externalActionsPending = state.recovery.evidence.externalActionsPending.filter(channel => ( + !['withdrawn', 'superseded'].includes(state.channels[channel]?.status) + )); + state.recovery.externalActionsPending = externalActionsPending; + state.recovery.status = externalActionsPending.length > 0 + ? 'provider-actions-pending' + : 'repository-completed'; + } if (!state.manifestSha256) delete state.manifestSha256; if (!state.promotion) delete state.promotion; + if (!state.recovery) delete state.recovery; return state; } diff --git a/cli/scripts/release/first-release-withdrawal.js b/cli/scripts/release/first-release-withdrawal.js new file mode 100644 index 0000000..c14bf50 --- /dev/null +++ b/cli/scripts/release/first-release-withdrawal.js @@ -0,0 +1,190 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const semver = require('semver'); +const {materializeReleaseState, readEvents} = require('./events'); +const {releaseTrainChannels} = require('./lib'); +const {reconcileRelease} = require('./reconcile'); + +const CATALOG_PATHS = Object.freeze([ + 'Formula/guardscan.rb', + 'bucket/guardscan.json', + 'channel-lock.json', +]); +const MAX_CONTROL_FILE_BYTES = 1024 * 1024; + +function pathExists(file) { + try { + fs.lstatSync(file); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +} + +function readText(file, label) { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`${label} must be a regular file`); + } + if (stat.size > MAX_CONTROL_FILE_BYTES) { + throw new Error(`${label} exceeds ${MAX_CONTROL_FILE_BYTES} bytes`); + } + return fs.readFileSync(file, 'utf8'); +} + +function readJson(file, label) { + let value; + try { + value = JSON.parse(readText(file, label)); + } catch (error) { + throw new Error(`${label} is invalid: ${error.message}`); + } + return value; +} + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function assertFirstReleaseWithdrawal(ledgerRoot, defectiveVersion, ledgerCommit) { + if (!semver.valid(defectiveVersion) || semver.prerelease(defectiveVersion) !== null) { + throw new Error('first-release withdrawal requires a stable defective version'); + } + if (!/^[a-f0-9]{40}$/.test(ledgerCommit || '')) { + throw new Error('first-release withdrawal requires the protected ledger commit'); + } + const root = path.resolve(ledgerRoot); + const eventsRoot = path.join(root, 'events'); + const defectiveLedger = path.join(eventsRoot, `v${defectiveVersion}.jsonl`); + if (!pathExists(defectiveLedger)) { + throw new Error('defective release has no protected ledger'); + } + const defectiveState = materializeReleaseState(readEvents(defectiveLedger)); + const alreadyCompleted = defectiveState.recovery?.mode === 'first-release-withdrawal' + && ['repository-completed', 'provider-actions-pending'].includes( + defectiveState.recovery?.status + ); + const active = readJson(path.join(root, 'active-versions.json'), 'active release trains'); + const activeTarget = active.trains?.some(train => ( + train?.version === defectiveVersion && train?.channel === 'stable' + )); + if (active.schemaVersion !== 'guardscan.active-trains.v1' + || !Array.isArray(active.trains) + || (!alreadyCompleted && !activeTarget) + || (alreadyCompleted && activeTarget)) { + throw new Error('defective stable release is not an active protected train'); + } + const selectedChannels = Object.keys(defectiveState.channels).sort(); + const stableChannels = releaseTrainChannels('stable').sort(); + const stableWithCore = releaseTrainChannels('stable', {homebrewCoreEnabled: true}).sort(); + if (![stableChannels, stableWithCore].some(expected => ( + expected.length === selectedChannels.length + && expected.every((channel, index) => channel === selectedChannels[index]) + ))) { + throw new Error('defective release ledger is not an exact stable release train'); + } + if (alreadyCompleted) { + const evidence = defectiveState.recovery.evidence; + const pending = evidence?.externalActionsPending; + if (evidence?.mode !== 'first-release-withdrawal' + || !evidence.catalog + || !Array.isArray(pending) + || new Set(pending).size !== pending.length + || pending.some(channel => typeof channel !== 'string') + || pending.join('\n') !== [...pending].sort().join('\n')) { + throw new Error('completed first-release withdrawal evidence is incomplete'); + } + const requested = new Set((defectiveState.actionRequired || []).map(item => item.channel)); + for (const [channel, channelState] of Object.entries(defectiveState.channels)) { + if (pending.includes(channel)) { + if (!requested.has(channel)) { + throw new Error(`completed withdrawal has no external action request for ${channel}`); + } + continue; + } + if (!['withdrawn', 'superseded'].includes(channelState.status)) { + throw new Error(`completed withdrawal left ${channel} non-terminal`); + } + } + } + const completed = []; + for (const name of fs.readdirSync(eventsRoot).sort()) { + const match = /^v(.+)\.jsonl$/.exec(name); + if (!match || match[1] === defectiveVersion) continue; + const version = match[1]; + if (!semver.valid(version) + || semver.prerelease(version) !== null + || !semver.lt(version, defectiveVersion)) continue; + const events = readEvents(path.join(eventsRoot, name)); + if (events.some((_, index) => ( + reconcileRelease(materializeReleaseState(events.slice(0, index + 1))).complete + ))) { + completed.push(version); + } + } + if (completed.length > 0) { + throw new Error( + `verified stable predecessors exist (${completed.join(', ')}); a known-good rollback is required` + ); + } + return { + schemaVersion: 'guardscan.first-release-withdrawal-authority.v1', + verified: true, + defectiveVersion, + defectiveTag: defectiveState.tag, + defectiveCommit: defectiveState.commit, + ledgerCommit, + priorCompleteStableVersions: [], + alreadyCompleted, + }; +} + +function prepareFirstReleaseCatalogWithdrawal(catalogRoot, defectiveVersion, defectiveCommit) { + if (!semver.valid(defectiveVersion) || semver.prerelease(defectiveVersion) !== null) { + throw new Error('catalog withdrawal requires a stable defective version'); + } + if (!/^[a-f0-9]{40}$/.test(defectiveCommit || '')) { + throw new Error('catalog withdrawal requires the defective source commit'); + } + const root = path.resolve(catalogRoot); + const present = CATALOG_PATHS.filter(relative => pathExists(path.join(root, relative))); + if (present.length === 0) { + return {changed: false, state: 'already-absent', removed: []}; + } + if (present.length !== CATALOG_PATHS.length) { + throw new Error(`catalog is partial and cannot be withdrawn safely: ${present.join(', ')}`); + } + const lock = readJson(path.join(root, 'channel-lock.json'), 'channel catalog lock'); + const formula = readText(path.join(root, 'Formula/guardscan.rb'), 'Homebrew formula'); + const scoopText = readText(path.join(root, 'bucket/guardscan.json'), 'Scoop manifest'); + const scoop = JSON.parse(scoopText); + const lockFiles = Object.keys(lock.files || {}).sort(); + if (lock.schemaVersion !== 'guardscan.channel-catalog.v1' + || lock.source?.repository !== 'ntanwir10/GuardScan' + || lock.source?.version !== defectiveVersion + || lock.source?.tag !== `v${defectiveVersion}` + || lock.source?.commit !== defectiveCommit + || !/^https:\/\//.test(lock.source?.manifestUrl || '') + || !/^[a-f0-9]{64}$/.test(lock.source?.manifestSha256 || '') + || lock.generator?.repository !== 'ntanwir10/GuardScan' + || lock.generator?.commit !== lock.source.commit + || lockFiles.join('\n') !== CATALOG_PATHS.slice(0, 2).sort().join('\n') + || lock.files['Formula/guardscan.rb']?.sha256 !== sha256(formula) + || lock.files['bucket/guardscan.json']?.sha256 !== sha256(scoopText) + || scoop.version !== defectiveVersion + || !formula.includes(` version "${defectiveVersion}"`)) { + throw new Error('catalog does not exactly identify the defective first release'); + } + for (const relative of CATALOG_PATHS) fs.unlinkSync(path.join(root, relative)); + return {changed: true, state: 'removed', removed: [...CATALOG_PATHS]}; +} + +module.exports = { + CATALOG_PATHS, + assertFirstReleaseWithdrawal, + prepareFirstReleaseCatalogWithdrawal, +}; diff --git a/cli/scripts/release/index.js b/cli/scripts/release/index.js index 09c1a6d..32e4d78 100644 --- a/cli/scripts/release/index.js +++ b/cli/scripts/release/index.js @@ -9,6 +9,7 @@ const { assertStateReferencesManifest, createPlan, prepareRelease, + readBounded, summarizeState, validateDocument, validateSource, @@ -39,7 +40,7 @@ const { } = require('./events'); const {createPromotionDecision} = require('./promotion'); const {classifyRemoteArtifact} = require('./remote'); -const {planRollback, reconcileRelease} = require('./reconcile'); +const {planFirstReleaseWithdrawal, planRollback, reconcileRelease} = require('./reconcile'); const { verifyManifestFiles, writeReleaseManifest, @@ -48,8 +49,15 @@ const {buildWheel, finalizeWheelArtifact} = require('./python-wheel'); const {writeArtifactSboms} = require('./artifact-sbom'); const {buildStandaloneArtifact} = require('./standalone-artifact'); const {createReleaseCandidate} = require('./candidate'); - -const BOOLEAN_OPTIONS = new Set(['accepted', 'check', 'native', 'requireNative']); +const {createPublicationEvidence} = require('./publication-evidence'); + +const BOOLEAN_OPTIONS = new Set([ + 'accepted', + 'check', + 'firstReleaseWithdrawal', + 'native', + 'requireNative', +]); const COMMANDS = new Set([ 'validate', 'candidate', @@ -106,7 +114,7 @@ function printHelp() { ' verify Verify local/remote identities and record acceptance or public verification', ' reconcile Materialize the ledger and plan only incomplete remote operations', ' promote Generate the machine 24-hour promotion decision', - ' rollback Append rollback_started and produce a forward-fix recovery plan', + ' rollback Append rollback_started and produce a verified recovery plan', ' status Materialize and summarize release state', ' catalog Render or check the authoritative shared channel catalog', ' catalog-status Classify shared catalog drift against the exact release source', @@ -132,12 +140,15 @@ function printHelp() { ' --artifact-id ID Manifest artifact identity', ' --remote-identity ID Immutable public identity', ' --remote-digest SHA256 Observed public SHA-256', + ' --publication-evidence P Provider-bound file evidence for GitHub, npm, or PyPI', ' --manifest-url URL Immutable GitHub release-manifest.json URL', ' --manifest-sha256 SHA Exact release-manifest.json SHA-256', ' --generator-repository R Repository containing the catalog renderer', ' --generator-commit SHA Exact renderer source commit', ' --known-good VERSION Verified stable rollback source version', ' --known-good-commit SHA Exact verified rollback source commit', + ' --first-release-withdrawal Withdraw the first stable train without inventing a baseline', + ' --first-release-authority P Protected-ledger authority document for first withdrawal', '', ].join('\n')); } @@ -325,8 +336,32 @@ function handlePublication(command, source, manifest, options) { const artifact = manifestArtifact(manifest, options.artifactId); if (options.artifactRoot) verifyManifestFiles(manifest, options.artifactRoot); const catalog = catalogEvidence(options, options.manifest); + const primaryPublication = command === 'publish' + && ['github', 'npm', 'pypi'].includes(options.channel); + let publication; + if (primaryPublication) { + if (!options.publicationEvidence) { + throw new Error(`${options.channel} publication requires --publication-evidence`); + } + const publicationFile = path.resolve(options.publicationEvidence); + let input; + try { + input = JSON.parse(readBounded(publicationFile, 'provider publication evidence')); + } catch (error) { + throw new Error(`provider publication evidence is invalid: ${error.message}`); + } + publication = createPublicationEvidence(input); + if (publication.channel !== options.channel + || publication.version !== source.version + || publication.tag !== source.tag + || publication.remoteIdentity !== options.remoteIdentity + || publication.aggregateSha256 !== options.remoteDigest + || publication.files[artifact.filename] !== artifact.sha256) { + throw new Error('provider publication evidence does not match the exact release artifact'); + } + } const classification = classifyRemoteArtifact( - {sha256: catalog?.fileDigest || artifact.sha256}, + {sha256: publication?.aggregateSha256 || catalog?.fileDigest || artifact.sha256}, {identity: options.remoteIdentity, sha256: options.remoteDigest} ); if (classification.integrityIncident) { @@ -349,6 +384,7 @@ function handlePublication(command, source, manifest, options) { remoteIdentity: options.remoteIdentity, remoteDigest: options.remoteDigest, ...(catalog ? {catalog} : {}), + ...(publication ? {publication} : {}), }, options.channel)); return {changed: result.changed, classification, event: result.event}; } @@ -432,6 +468,8 @@ async function main(argv) { 'candidateVersion', 'sourcePr', 'sourcePrHead', + 'sourcePrBase', + 'sourcePrTree', 'timestamp', ]); process.stdout.write(`${JSON.stringify(createReleaseCandidate( @@ -439,6 +477,8 @@ async function main(argv) { options.candidateVersion, options.sourcePr, options.sourcePrHead, + options.sourcePrBase, + options.sourcePrTree, options.timestamp ), null, 2)}\n`); return; @@ -496,22 +536,65 @@ async function main(argv) { } if (command === 'rollback') { - requireOptions('rollback', options, [ - 'ledger', - 'timestamp', - 'idempotencyKey', - 'knownGood', - 'knownGoodCommit', - ]); + requireOptions('rollback', options, ['ledger', 'timestamp', 'idempotencyKey']); + if (options.firstReleaseWithdrawal && (options.knownGood || options.knownGoodCommit)) { + throw new Error('first-release withdrawal cannot accept a known-good release'); + } + if (options.firstReleaseWithdrawal && !options.firstReleaseAuthority) { + throw new Error('first-release withdrawal requires --first-release-authority'); + } + if (!options.firstReleaseWithdrawal) { + requireOptions('rollback', options, ['knownGood', 'knownGoodCommit']); + } const materialized = materializeReleaseState(readEvents(options.ledger)); - const plan = planRollback(materialized, options.knownGood, options.knownGoodCommit); + let firstReleaseAuthority; + if (options.firstReleaseWithdrawal) { + try { + firstReleaseAuthority = JSON.parse(readBounded( + path.resolve(options.firstReleaseAuthority), + 'first-release withdrawal authority' + )); + } catch (error) { + throw new Error(`first-release withdrawal authority is invalid: ${error.message}`); + } + } + const plan = options.firstReleaseWithdrawal + ? planFirstReleaseWithdrawal(materialized, firstReleaseAuthority) + : planRollback(materialized, options.knownGood, options.knownGoodCommit); const results = []; - results.push(appendEvent(options.ledger, eventIdentity(source, options, 'rollback_started', { - knownGoodVersion: options.knownGood, - knownGoodCommit: options.knownGoodCommit, - forwardFixVersion: plan.forwardFixVersion, - forwardFixBranch: plan.forwardFixBranch, - }))); + results.push(appendEvent(options.ledger, eventIdentity( + source, + options, + 'rollback_started', + plan.mode === 'first-release-withdrawal' + ? { + mode: plan.mode, + requiredNextVersion: plan.requiredNextVersion, + } + : { + mode: plan.mode, + knownGoodVersion: options.knownGood, + knownGoodCommit: options.knownGoodCommit, + forwardFixVersion: plan.forwardFixVersion, + forwardFixBranch: plan.forwardFixBranch, + } + ))); + if (plan.mode === 'first-release-withdrawal') { + const incidentOptions = { + ...options, + idempotencyKey: `${options.idempotencyKey}:incident`, + }; + results.push(appendEvent(options.ledger, eventIdentity( + source, + incidentOptions, + 'incident_opened', + { + incidentId: `first-release-withdrawal-v${materialized.version}`, + kind: 'recovery', + summary: `v${materialized.version} requires withdrawal and a separately reviewed v${plan.requiredNextVersion}`, + } + ))); + } const authorityReasons = { npm: 'GitHub OIDC trusted publishing cannot deprecate an existing npm version', pypi: 'PyPI trusted publishing cannot yank an existing release', diff --git a/cli/scripts/release/promotion.js b/cli/scripts/release/promotion.js index 0cc8d12..909da13 100644 --- a/cli/scripts/release/promotion.js +++ b/cli/scripts/release/promotion.js @@ -59,6 +59,7 @@ function createPromotionDecision(input) { const reasons = []; if (evaluatedAt < eligibleAt) reasons.push('soak_window_incomplete'); if (input.rc.sourcePrHead !== input.currentSourcePrHead) reasons.push('source_pr_head_changed'); + if (input.rc.sourcePrBase !== input.currentSourcePrBase) reasons.push('source_pr_base_changed'); const activeIncidents = (input.incidents || []).filter(incident => incident.status === 'open'); if (activeIncidents.length > 0) reasons.push('active_release_incident'); @@ -92,12 +93,16 @@ function createPromotionDecision(input) { eligibleAt: eligibleAt.toISOString(), sourcePr: input.rc.sourcePr, sourcePrHead: input.rc.sourcePrHead, + sourcePrBase: input.rc.sourcePrBase, + sourcePrTree: input.rc.sourcePrTree, }, stable: { version, tag: `v${version}`, sourcePr: input.rc.sourcePr, sourcePrHead: input.currentSourcePrHead, + sourcePrBase: input.currentSourcePrBase, + sourcePrTree: input.rc.sourcePrTree, }, evaluatedAt: input.evaluatedAt, policy: { diff --git a/cli/scripts/release/publication-evidence.js b/cli/scripts/release/publication-evidence.js new file mode 100644 index 0000000..61f78b1 --- /dev/null +++ b/cli/scripts/release/publication-evidence.js @@ -0,0 +1,76 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const SCHEMA = 'guardscan.provider-publication.v1'; +const CHANNELS = new Set(['github', 'npm', 'pypi']); +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/; + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function aggregateFiles(files) { + const names = Object.keys(files).sort(); + if (names.length < 1 || names.length > 100) { + throw new Error('provider publication evidence requires 1-100 files'); + } + for (const name of names) { + if (name.length > 200 || !FILENAME_PATTERN.test(name) || !SHA256_PATTERN.test(files[name] || '')) { + throw new Error(`provider publication file identity is invalid: ${name}`); + } + } + return names.length === 1 + ? files[names[0]] + : sha256(names.map(name => `${name}\0${files[name]}\n`).join('')); +} + +function createPublicationEvidence(input) { + if (!CHANNELS.has(input.channel)) throw new Error('provider publication channel is invalid'); + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$/.test(input.version || '')) { + throw new Error('provider publication version is invalid'); + } + if (input.tag !== `v${input.version}`) throw new Error('provider publication tag is invalid'); + if (typeof input.remoteIdentity !== 'string' + || input.remoteIdentity.length < 1 + || input.remoteIdentity.length > 500) { + throw new Error('provider publication remote identity is invalid'); + } + const files = Object.fromEntries(Object.entries(input.files || {}).sort(([left], [right]) => ( + left < right ? -1 : left > right ? 1 : 0 + ))); + return { + schemaVersion: SCHEMA, + channel: input.channel, + version: input.version, + tag: input.tag, + remoteIdentity: input.remoteIdentity, + aggregateSha256: aggregateFiles(files), + files, + }; +} + +function writePublicationEvidence(file, input) { + const evidence = createPublicationEvidence(input); + const resolved = path.resolve(file); + fs.mkdirSync(path.dirname(resolved), {recursive: true, mode: 0o700}); + const text = `${JSON.stringify(evidence, null, 2)}\n`; + if (fs.existsSync(resolved)) { + if (fs.readFileSync(resolved, 'utf8') !== text) { + throw new Error(`provider publication evidence conflicts with existing file: ${resolved}`); + } + return {created: false, evidence}; + } + fs.writeFileSync(resolved, text, {encoding: 'utf8', mode: 0o600, flag: 'wx'}); + return {created: true, evidence}; +} + +module.exports = { + SCHEMA, + aggregateFiles, + createPublicationEvidence, + writePublicationEvidence, +}; diff --git a/cli/scripts/release/reconcile.js b/cli/scripts/release/reconcile.js index 5b96810..c9df653 100644 --- a/cli/scripts/release/reconcile.js +++ b/cli/scripts/release/reconcile.js @@ -36,6 +36,12 @@ function reconcileRelease(state) { action.action = operation === 'verify' ? 'verify' : operation; } else if (channelState.status === 'submitted') { action.action = 'poll-acceptance'; + } else if (channelState.status === 'rejected') { + action.action = 'prepare-correction'; + } else if (channelState.status === 'corrected') { + action.action = 'resubmit-correction'; + } else if (channelState.status === 'resubmitted') { + action.action = 'poll-resubmission'; } else if (channelState.status === 'accepted') { action.action = 'verify-public-install'; } else if (channelState.status === 'published') { @@ -105,6 +111,7 @@ function planRollback(state, knownGoodVersion, knownGoodCommit) { } return { schemaVersion: 'guardscan.rollback-plan.v1', + mode: 'known-good', version: state.version, tag: state.tag, commit: state.commit, @@ -127,4 +134,81 @@ function planRollback(state, knownGoodVersion, knownGoodCommit) { }; } -module.exports = {planRollback, reconcileRelease}; +function planFirstReleaseWithdrawal(state, authority) { + if (!semver.valid(state.version) || semver.prerelease(state.version) !== null) { + throw new Error('first-release withdrawal requires a stable defective release'); + } + if (authority?.schemaVersion !== 'guardscan.first-release-withdrawal-authority.v1' + || authority.verified !== true + || authority.defectiveVersion !== state.version + || authority.defectiveTag !== state.tag + || authority.defectiveCommit !== state.commit + || !/^[a-f0-9]{40}$/.test(authority.ledgerCommit || '') + || !Array.isArray(authority.priorCompleteStableVersions) + || authority.priorCompleteStableVersions.length !== 0) { + throw new Error('first-release withdrawal requires exact protected-ledger authority'); + } + const externalAuthority = { + npm: 'npm-maintainer', + pypi: 'pypi-maintainer', + 'homebrew-core': 'homebrew-core-maintainer', + winget: 'winget-maintainer', + chocolatey: 'chocolatey-maintainer', + }; + const actionByChannel = { + github: 'retain-immutable-and-mark-superseded', + npm: 'deprecate-defective-release', + pnpm: 'mark-defective-npm-consumer-superseded', + yarn: 'mark-defective-npm-consumer-superseded', + bun: 'mark-defective-npm-consumer-superseded', + pypi: 'yank-defective-release', + homebrew: 'remove-first-release-listing', + 'homebrew-core': 'submit-removal-or-revision', + scoop: 'remove-first-release-listing', + winget: 'submit-removal-or-corrective-manifest', + chocolatey: 'unlist-defective-release', + }; + const actions = []; + for (const [channel, channelState] of Object.entries(state.channels || {})) { + if (channelState.status === 'planned') { + actions.push({ + channel, + currentStatus: channelState.status, + action: 'confirm-unpublished-and-cancel', + automation: 'repository-automated', + }); + continue; + } + if (['withdrawn', 'superseded'].includes(channelState.status)) continue; + actions.push({ + channel, + currentStatus: channelState.status, + action: actionByChannel[channel], + automation: externalAuthority[channel] + ? 'external-action-required' + : 'repository-automated', + ...(externalAuthority[channel] ? {authority: externalAuthority[channel]} : {}), + }); + } + return { + schemaVersion: 'guardscan.rollback-plan.v1', + mode: 'first-release-withdrawal', + version: state.version, + tag: state.tag, + commit: state.commit, + status: 'planned', + requiredNextVersion: nextPatchVersion(state.version), + authority: { + ledgerCommit: authority.ledgerCommit, + priorCompleteStableVersions: [], + }, + repositoryActions: [ + {id: 'open-recovery-incident', action: 'retain-open-incident', status: 'planned'}, + {id: 'shared-catalog-withdrawal', action: 'remove-first-release-listing', status: 'planned'}, + {id: 'deactivate-train', action: 'remove-from-active-versions', status: 'planned'}, + ], + actions: actions.sort((a, b) => a.channel.localeCompare(b.channel)), + }; +} + +module.exports = {planFirstReleaseWithdrawal, planRollback, reconcileRelease}; diff --git a/cli/src/core/cost-guard.ts b/cli/src/core/cost-guard.ts index e023ab6..8031965 100644 --- a/cli/src/core/cost-guard.ts +++ b/cli/src/core/cost-guard.ts @@ -238,15 +238,6 @@ export class CostGuard { this.usage.exportToCSV(outputPath, days); } - /** - * Reset daily budget (for testing/admin) - */ - async resetDailyBudget(): Promise { - // This would require filtering records to keep only old ones - // For now, this is a placeholder - console.log('⚠️ Manual budget reset not yet implemented'); - } - /** * Clear all usage records */ diff --git a/docs/FUNCTIONAL_ACCEPTANCE.md b/docs/FUNCTIONAL_ACCEPTANCE.md index 8f6aa0d..7d0a1b1 100644 --- a/docs/FUNCTIONAL_ACCEPTANCE.md +++ b/docs/FUNCTIONAL_ACCEPTANCE.md @@ -87,7 +87,7 @@ not proved today. | WinGet | Generated portable manifest plus upstream state | Rendering, validation commands, and append-only submitted/accepted/verified states are implemented. | Local manifest install must pass, then upstream PR acceptance and public-catalog install must be observed. | | Chocolatey | Deterministic `.nupkg` plus moderated public state | Renderer, local validation command, and append-only moderation states are implemented. | Local-feed lifecycle must pass, then validation, verification, VirusTotal, moderation, and public install must complete. | | RC soak and promotion | Public canaries, unchanged PR head, no incident, 24-hour decision | Promotion policy and reconciliation contracts are tested. | At least 24 hourly green samples per required channel and a complete 24-hour wall-clock window are mandatory. | -| Rollback/forward fix | Append-only recovery evidence | Withdrawal, supersession, catalog correction, yanking/deprecation, and forward-fix planning are represented in contracts. | Rehearse a partial-publication failure before stable; never overwrite an immutable artifact. | +| Rollback/forward fix | Append-only recovery evidence | Known-good restoration and first-release withdrawal model supersession, exact catalog correction/removal, provider-owned yanking/deprecation, and recovery incidents without overwriting immutable artifacts. | Rehearse both a partial-publication failure and the no-predecessor first-release path before stable; require a separately reviewed patch when no verified baseline exists. | | Homebrew Core | Separately authorized submission and public-Core canary | A renderer/validator exists, but Core is not selected by the current release train. | A reviewed enablement change, Core submission/acceptance, and `brew install guardscan` public canary are required. It never blocks `1.1.0`. | ## Install and runtime variants diff --git a/docs/RELEASE_AUTOMATION.md b/docs/RELEASE_AUTOMATION.md index 4244b5f..85a2a91 100644 --- a/docs/RELEASE_AUTOMATION.md +++ b/docs/RELEASE_AUTOMATION.md @@ -20,7 +20,7 @@ evidence until it is bound to the selected commit and exact artifact. - Missing remote versions are published. Identical remote digests are accepted as retries. Different remote digests open an integrity incident and stop the train. - Stable promotion is a machine decision after a full 24-hour window. It requires an unchanged release-PR head, fresh green canaries for every RC channel, and no open release incident. - WinGet and Chocolatey remain `submitted` until their public catalogs accept them and a clean public installation passes. -- Rollback never mutates history or overwrites a release. It appends recovery events and prepares a forward-fix patch. +- Recovery never mutates history or overwrites a release. Normal rollback restores a verified known-good source through a forward-fix patch; the first stable train instead fails closed into withdrawal when no verified baseline exists. ## Selected channels @@ -41,6 +41,7 @@ Core requires a separate reviewed enablement and remains nonblocking. | `.github/workflows/ci.yml` | Required source, test, coverage, package, package-manager, audit, and five-host SEA gates. It cannot publish. | | `.github/workflows/release-please.yml` | Maintains the stable release PR only, using a short-lived GitHub App token. | | `.github/workflows/release-train.yml` | Derives RC commits, creates protected tags, dispatches builds/publication, reconciles every 30 minutes, promotes, rolls back, and persists release events. | +| `.github/workflows/release-first-withdrawal.yml` | Withdraws the first stable train only when the protected ledger proves no verified predecessor exists; it never invents forward-fix source. | | `.github/workflows/release-build.yml` | Builds the exact npm tarball and five SEA targets, signs, notarizes, generates SPDX/CycloneDX, creates wheels, attests, archives deterministically, and aggregates the manifest/checksums. | | `.github/workflows/release-publish.yml` | Publishes tested registry handoffs through OIDC and opens the generated shared-catalog update PR. | | `.github/workflows/release-canary.yml` | Runs hourly public install/invoke/uninstall canaries and polls moderated registries. | @@ -219,7 +220,11 @@ any active state -> failed published/verified -> withdrawn or superseded ``` -Rollback is represented by `rollback_started`, `withdrawn`, and `superseded` events; no backward state mutation is needed. Integrity, security, and availability incidents use `incident_opened` and `incident_resolved`. +Rollback is represented by `rollback_started`, `withdrawn`, and `superseded` +events; no backward state mutation is needed. Integrity, security, +availability, and recovery incidents use `incident_opened` and +`incident_resolved`. An `action_required` event records a provider-owned task; +it does not claim that a package has been withdrawn. The repository also contains: @@ -232,8 +237,11 @@ The repository also contains: Before the first candidate, merge the inert automation bootstrap to `main` while `RELEASE_AUTOMATION_ENABLED=false`, complete every onboarding check, and -verify that the release PR head and full gate are unchanged. Then set the -variable to `true` and start the candidate from the default-branch workflow: +verify that the release PR head and full gate are unchanged. Refetch the +protected `release-ledger` branch and fail if its tree contains anything other +than the empty `active-versions.json` seed; this is the explicit no-migration +boundary for the first source-bound event schema. Then set the variable to +`true` and start the candidate from the default-branch workflow: ```bash gh workflow run release-train.yml \ @@ -304,6 +312,42 @@ After stable publication: - WinGet receives a corrective manifest; - a higher patch version is prepared from selected known-good source. +The first ledger-backed stable release is the only exception to the +known-good requirement. Dispatching rollback without `known_good` invokes the +separate first-release withdrawal workflow. It is authorized only when the +protected ledger proves that the defective version is the active first stable +train and that no earlier stable ledger ever reached completion. The workflow: + +- retains immutable GitHub assets and marks them superseded; +- removes only an exact matching `Formula/guardscan.rb`, + `bucket/guardscan.json`, and `channel-lock.json`, or records that the catalog + was already in the valid empty bootstrap state; it also closes the exact + still-open publication PR so that a delayed merge cannot restore the listing; +- appends provider-owned npm, PyPI, WinGet, Chocolatey, and optional Core + actions without claiming they have completed; +- records `provider-actions-pending` while any of those external actions remain, + keeps a recovery incident open, deactivates the train only after repository + evidence is persisted, and requires a separately reviewed next patch; +- makes retries prove the active train is absent, repository-owned channels are + terminal, the catalog is still empty, and no publication PR remains open; +- never creates a forward-fix branch from the defective source. + +Once any earlier stable release has reached completion, omitting `known_good` +fails closed and the normal verified-baseline rollback is mandatory. + +```bash +# First ledger-backed stable release only; protected-ledger proof is mandatory. +gh workflow run release-train.yml \ + -f action=rollback \ + -f version=1.1.0 + +# Every later stable release requires an exact verified predecessor. +gh workflow run release-train.yml \ + -f action=rollback \ + -f version=DEFECTIVE_VERSION \ + -f known_good=VERIFIED_PREDECESSOR +``` + A release is complete only when every selected blocking channel materializes as `verified`. Optional Homebrew Core submission/acceptance is tracked separately and never blocks the release train. diff --git a/docs/RELEASE_ONBOARDING.md b/docs/RELEASE_ONBOARDING.md index a55a507..6c691c3 100644 --- a/docs/RELEASE_ONBOARDING.md +++ b/docs/RELEASE_ONBOARDING.md @@ -52,7 +52,11 @@ required first-release exception. - Seed the orphan `release-ledger` branch from `.github/release-ledger/active-versions.json`, then protect the branch and require the App identity for writes. Do not copy application source onto the - ledger branch. + ledger branch. Before merging the bootstrap, refetch this branch and require + its complete tree to contain only `active-versions.json` with an empty + `trains` array. The first source-bound and moderated event contracts have no + migration path from experimental pre-release ledger records; any unexpected + `events/` path is a launch blocker, not data to coerce. - Protect `v*` tags so only the release App can create them. - Enable immutable releases for GuardScan. - Enable squash merge and auto-merge, and require the full `Release gate` @@ -233,6 +237,21 @@ PR head, require the normal release gates, and merge/tag it through the release train. After `v1.1.0` is verified, align the Release Please manifest to `1.1.0` so subsequent stable release PRs follow the normal automated path. +Because v1.0.5 predates the protected release ledger, it is not a valid +automated rollback target for this train. Before enabling automation, rehearse +the first-release withdrawal contract: an empty `known_good` must be accepted +only when the protected ledger proves there is no completed predecessor; it +must retain immutable assets, remove or verify the exact empty shared catalog, +open a recovery incident, record provider-owned actions, and avoid generating +a forward-fix branch. A separately reviewed v1.1.1 is required after such a +withdrawal. Later releases must supply a verified ledger-backed `known_good`. +Repository withdrawal is not provider withdrawal: the protected state remains +`provider-actions-pending`, with the recovery incident open, until the recorded +npm, PyPI, WinGet, Chocolatey, or optional Core authorities complete their +actions. Retrying the workflow re-verifies the empty catalog and closes the +exact stale catalog publication PR; it does not turn pending provider work into +success. + ## Expiry monitoring Credential health is a launch gate, not an informal maintainer reminder. From 4fa92c700c5545be54a41b51cf12297b780345b2 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 9 Aug 2026 17:42:51 -0400 Subject: [PATCH 22/23] install canary ledger dependencies --- .github/workflows/release-canary.yml | 6 ++++++ cli/__tests__/scripts/release-workflows.test.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index ec829d2..d1f43a5 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -563,6 +563,12 @@ jobs: ref: ${{ github.event.repository.default_branch }} fetch-depth: 0 token: ${{ steps.app.outputs.token }} + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.RELEASE_NODE_VERSION }} + - name: Install default-branch ledger tooling + working-directory: cli + run: npm ci - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: canary-* diff --git a/cli/__tests__/scripts/release-workflows.test.ts b/cli/__tests__/scripts/release-workflows.test.ts index f4290e6..4848d15 100644 --- a/cli/__tests__/scripts/release-workflows.test.ts +++ b/cli/__tests__/scripts/release-workflows.test.ts @@ -117,6 +117,10 @@ describe('zero-touch release workflow contracts', () => { expect(train).not.toContain('stable-promotion-approval'); expect(canary).toContain('cron: "7 * * * *"'); expect(canary).toContain('group: release-ledger'); + expect(canary).toContain('Install default-branch ledger tooling'); + expect(canary).toMatch( + /record:\n[\s\S]*?actions\/setup-node@[a-f0-9]+[\s\S]*?working-directory: cli\n\s+run: npm ci/ + ); expect(train).toContain('samples.length >= 24'); expect(train).toContain("types: [catalog_updated]"); expect(train).toContain('hinted-channel-lock.json'); From 7db43b8204a8b67aee9f94681587ff1369649b26 Mon Sep 17 00:00:00 2001 From: Nauman Tanwir Date: Sun, 9 Aug 2026 17:49:42 -0400 Subject: [PATCH 23/23] normalize RAG repository paths --- cli/__tests__/integration/rag-e2e.test.ts | 5 +++++ cli/__tests__/utils/path-helper.test.ts | 18 +++++++++++++++++- cli/src/core/codebase-indexer.ts | 7 ++++--- cli/src/core/embedding-chunker.ts | 19 +++++++++++-------- cli/src/utils/path-helper.ts | 10 ++++++++++ 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/cli/__tests__/integration/rag-e2e.test.ts b/cli/__tests__/integration/rag-e2e.test.ts index d2638e1..9126a8c 100644 --- a/cli/__tests__/integration/rag-e2e.test.ts +++ b/cli/__tests__/integration/rag-e2e.test.ts @@ -155,6 +155,11 @@ and user management backed by a database. expect(indexed.stats.embeddingsGenerated).toBeGreaterThan(2); expect(await store.exists()).toBe(true); + const persistedEmbeddings = await store.loadEmbeddings(); + expect(persistedEmbeddings.every(embedding => !path.isAbsolute(embedding.source))).toBe(true); + expect(persistedEmbeddings.every(embedding => !embedding.source.includes('\\'))).toBe(true); + expect(persistedEmbeddings.every(embedding => !embedding.content.includes(repository))).toBe(true); + const auth = await search.search('How does user login authentication verify a password?', { k: 5, minSimilarity: 0.2, diff --git a/cli/__tests__/utils/path-helper.test.ts b/cli/__tests__/utils/path-helper.test.ts index d0311c1..b03e031 100644 --- a/cli/__tests__/utils/path-helper.test.ts +++ b/cli/__tests__/utils/path-helper.test.ts @@ -1,9 +1,25 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { ensureDirectoryExists, getSafeHomeDir } from '../../src/utils/path-helper'; +import { + ensureDirectoryExists, + getSafeHomeDir, + repositoryRelativePath, +} from '../../src/utils/path-helper'; describe('path-helper private state handling', () => { + it('normalizes repository paths to portable POSIX separators', () => { + const repoRoot = path.join(os.tmpdir(), 'workspace', 'project'); + const authPath = path.join(repoRoot, 'src', 'auth.ts'); + + expect(repositoryRelativePath(repoRoot, authPath)) + .toBe('src/auth.ts'); + expect(repositoryRelativePath(repoRoot, 'src/auth.ts')) + .toBe('src/auth.ts'); + expect(repositoryRelativePath(repoRoot, 'src\\auth.ts')) + .toBe('src/auth.ts'); + }); + const originalEnv = { ...process.env }; afterEach(() => { diff --git a/cli/src/core/codebase-indexer.ts b/cli/src/core/codebase-indexer.ts index 8529838..5dd1977 100644 --- a/cli/src/core/codebase-indexer.ts +++ b/cli/src/core/codebase-indexer.ts @@ -8,6 +8,7 @@ import { ParsedClass, } from "./ast-parser"; import { configManager } from "./config"; +import { repositoryRelativePath } from "../utils/path-helper"; /** * Symbol information in the codebase @@ -360,7 +361,7 @@ export class CodebaseIndexer { const index = await this.loadIndex(); if (!index) {return null;} - const relativePath = path.relative(this.repoRoot, filePath); + const relativePath = repositoryRelativePath(this.repoRoot, filePath); return index.files.get(relativePath) || null; } @@ -390,7 +391,7 @@ export class CodebaseIndexer { filePath: string, index: CodebaseIndex ): Promise { - const relativePath = path.relative(this.repoRoot, filePath); + const relativePath = repositoryRelativePath(this.repoRoot, filePath); // Parse file const parsed = await this.getParsedFile(filePath); @@ -488,7 +489,7 @@ export class CodebaseIndexer { * Remove file from index */ private removeFileFromIndex(filePath: string, index: CodebaseIndex): void { - const relativePath = path.relative(this.repoRoot, filePath); + const relativePath = repositoryRelativePath(this.repoRoot, filePath); const fileIndex = index.files.get(relativePath); if (!fileIndex) {return;} diff --git a/cli/src/core/embedding-chunker.ts b/cli/src/core/embedding-chunker.ts index ea60d1b..98297c8 100644 --- a/cli/src/core/embedding-chunker.ts +++ b/cli/src/core/embedding-chunker.ts @@ -11,6 +11,7 @@ import fastGlob from 'fast-glob'; import { CodebaseIndexer, CodebaseIndex } from './codebase-indexer'; import { ParsedFunction, ParsedClass } from './ast-parser'; import { CodeChunk, EmbeddingMetadata, hashContent } from './embeddings'; +import { repositoryRelativePath } from '../utils/path-helper'; export interface ChunkingOptions { maxFunctionSize?: number; // Max chars for function chunks (default: 2000) @@ -110,7 +111,8 @@ export class EmbeddingChunker { continue; } - const content = this.formatFunctionForEmbedding(func); + const source = repositoryRelativePath(this.repoRoot, func.file); + const content = this.formatFunctionForEmbedding(func, source); // Skip if too large if (content.length > options.maxFunctionSize!) { @@ -131,7 +133,7 @@ export class EmbeddingChunker { tags: this.generateTags(func), lastModified: await this.getFileModificationTime(func.file), }, - source: func.file, + source, startLine: func.line, endLine: func.endLine, }); @@ -150,7 +152,8 @@ export class EmbeddingChunker { const chunks: CodeChunk[] = []; for (const [classId, cls] of index.classes) { - const content = this.formatClassForEmbedding(cls); + const source = repositoryRelativePath(this.repoRoot, cls.file); + const content = this.formatClassForEmbedding(cls, source); // Skip if too large if (content.length > options.maxClassSize!) { @@ -171,7 +174,7 @@ export class EmbeddingChunker { tags: this.generateClassTags(cls), lastModified: await this.getFileModificationTime(cls.file), }, - source: cls.file, + source, startLine: cls.line, endLine: cls.endLine, }); @@ -302,11 +305,11 @@ export class EmbeddingChunker { /** * Format function with context for better embeddings */ - private formatFunctionForEmbedding(func: ParsedFunction): string { + private formatFunctionForEmbedding(func: ParsedFunction, source: string): string { const parts: string[] = []; // File context - parts.push(`// File: ${func.file}`); + parts.push(`// File: ${source}`); parts.push(`// Function: ${func.name}`); // Documentation @@ -339,11 +342,11 @@ export class EmbeddingChunker { /** * Format class with context */ - private formatClassForEmbedding(cls: ParsedClass): string { + private formatClassForEmbedding(cls: ParsedClass, source: string): string { const parts: string[] = []; // File context - parts.push(`// File: ${cls.file}`); + parts.push(`// File: ${source}`); parts.push(`// Class: ${cls.name}`); // Documentation diff --git a/cli/src/utils/path-helper.ts b/cli/src/utils/path-helper.ts index 9d1f60d..e47dd08 100644 --- a/cli/src/utils/path-helper.ts +++ b/cli/src/utils/path-helper.ts @@ -2,6 +2,16 @@ import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; +/** + * Return a stable repository-relative path for persisted data and user output. + */ +export function repositoryRelativePath(repoRoot: string, filePath: string): string { + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(repoRoot, filePath); + return path.relative(repoRoot, absolutePath).replace(/\\/g, '/'); +} + /** * Get home directory with fallbacks for containerized environments * Handles Alpine Docker and other edge cases where os.homedir() may fail