diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5fd817..e10d50f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ name: ci on: + workflow_call: pull_request: push: branches: [main] @@ -92,7 +93,43 @@ jobs: shell: pwsh run: ./tools/Test-RepoConformance.ps1 -SelfTest + - name: Standards package tests + shell: pwsh + run: ./tools/Test-StandardsPackage.ps1 + # Static mode reads only the checkout: no API calls, no admin scope needed. - name: Static conformance of this repository shell: pwsh run: ./tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly + + release-please: + name: release-please + needs: [security, codeql, docs, conformance] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.RELEASE_APP_CLIENT_ID != '' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + # EX-0006: a main-only bot key, never a repository secret. + environment: release-automation + concurrency: + group: standards-release-pr + cancel-in-progress: false + permissions: {} + steps: + # An App token lets the generated PR trigger CI; GITHUB_TOKEN does not. + - name: Release PR token + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Propose a semantic release + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.app.outputs.token }} + target-branch: main + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: true diff --git a/.github/workflows/release-standards.yml b/.github/workflows/release-standards.yml new file mode 100644 index 0000000..8f4870f --- /dev/null +++ b/.github/workflows/release-standards.yml @@ -0,0 +1,175 @@ +name: release-standards + +on: + workflow_call: + +permissions: {} + +jobs: + validate: + name: validate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + issues: read + pull-requests: read + outputs: + version: ${{ steps.tag.outputs.version }} + checklist: ${{ steps.tag.outputs.checklist }} + pr: ${{ steps.tag.outputs.pr }} + steps: + - name: Checkout tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate version, main ancestry, and checklist + id: tag + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + [[ "$GITHUB_REF" == refs/tags/v* ]] + [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { + echo '::error::Standards bundles require a stable vMAJOR.MINOR.PATCH tag.' + exit 1 + } + git merge-base --is-ancestor HEAD origin/main + version="${TAG#v}" + [[ "$(cat version.txt)" == "$version" ]] + [[ "$(jq -r '."."' .release-please-manifest.json)" == "$version" ]] + echo "version=$version" >> "$GITHUB_OUTPUT" + commit="$(git rev-parse HEAD)" + pr="$(gh api "repos/$GH_REPO/commits/$commit/pulls" | jq -r --arg sha "$commit" '.[] | select(.merge_commit_sha == $sha and .merged_at != null and .base.ref == "main" and any(.labels[]; .name == "autorelease: pending")) | .number')" + [[ "$pr" =~ ^[1-9][0-9]*$ ]] || { + echo '::error::Tag the merge commit of the pending release PR.' + exit 1 + } + echo "pr=$pr" >> "$GITHUB_OUTPUT" + # Preflight is approved before tagging; shipping checks follow publication. + checklist='' + if [[ "$version" == *.0 ]]; then + annotation="$(git for-each-ref --format='%(contents)' "refs/tags/$TAG")" + prefix="https://github.com/$GH_REPO/issues/" + checklist="$(printf '%s\n' "$annotation" | grep -F 'Release checklist: ' | head -n 1 | sed 's/^Release checklist: //')" + [[ "$checklist" == "$prefix"* ]] + issue="${checklist#"$prefix"}" + [[ "$issue" =~ ^[1-9][0-9]*$ ]] + gh api "repos/$GH_REPO/issues/$issue" | jq -e \ + '.pull_request == null and any(.labels[]; .name == "release: ready")' >/dev/null + fi + echo "checklist=$checklist" >> "$GITHUB_OUTPUT" + + gates: + name: gates + needs: validate + uses: ./.github/workflows/ci.yml + permissions: + contents: read + security-events: write + + build: + name: build + needs: [validate, gates] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Checkout tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build committed standards bundle + shell: pwsh + env: + K22_VERSION: ${{ needs.validate.outputs.version }} + RELEASE_CHECKLIST: ${{ needs.validate.outputs.checklist }} + run: ./tools/Build-StandardsPackage.ps1 -Version $env:K22_VERSION -ChecklistUrl $env:RELEASE_CHECKLIST + + - name: Attest every release asset + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: artifacts/standards/* + + - name: Upload release assets + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: standards-release-assets + path: artifacts/standards/* + if-no-files-found: error + + publish: + name: publish + needs: [validate, build] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: release + permissions: + contents: write + pull-requests: write + steps: + - name: Download release assets + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: standards-release-assets + path: release + + - name: Settings read token + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-administration: read + + - name: Require immutable releases + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + [[ "$(gh api "repos/$GH_REPO/immutable-releases" --jq '.enabled')" == true ]] + + - name: Create draft with all assets + working-directory: release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + K22_VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + sha256sum --check SHA256SUMS + # Creation fails if this tag already has a release. Never replace assets. + gh release create "$TAG" --draft --verify-tag \ + --title "KofTwentyTwo standards $K22_VERSION" \ + --notes-file "KofTwentyTwo-standards-$K22_VERSION.notes.md" ./* + + - name: Publish immutable release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + gh release edit "$TAG" --draft=false + [[ "$(gh api "repos/$GH_REPO/releases/tags/$TAG" --jq '.immutable')" == true ]] + + # Release Please will not propose another version while a merged PR is pending. + - name: Mark the release PR as published + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ needs.validate.outputs.pr }} + run: | + set -euo pipefail + gh label create 'autorelease: tagged' --color 0e8a16 --force + gh pr edit "$PR_NUMBER" --remove-label 'autorelease: pending' --add-label 'autorelease: tagged' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a333a5e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,24 @@ +name: standards-release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: {} + +concurrency: + group: standards-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/workflows/release-standards.yml + permissions: + contents: write + issues: read + pull-requests: write + security-events: write + id-token: write + attestations: write diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7191894 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository publishes KofTwentyTwo engineering standards and enforcement tooling. +`policies/` defines requirements; `standards/` provides implementation and language +profiles; `architecture/` contains reference designs. `configs/` holds IDE, formatter, +linter, and scanner settings. `templates/` contains starter repository files and +workflow callers; `.github/workflows/` contains reusable workflows and repository CI. +PowerShell tooling lives in `tools/`, workstation setup in `setup/`, framework +crosswalks in `compliance/`, and approved deviations in `exceptions/`. + +## Build, Test, and Development Commands + +There is no application build or local server. Run these checks from the repository +root with the corresponding tools installed: + +```powershell +npx markdownlint-cli2 "**/*.md" +lychee --offline --include-fragments --no-progress './**/*.md' +pwsh tools/Test-RepoConformance.ps1 -SelfTest +pwsh tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly +zizmor .github/workflows +actionlint +gitleaks detect --source . --no-banner +``` + +These check Markdown, internal links and anchors, checker behavior, local conformance, +workflow security, workflow syntax, and secrets respectively. For GitHub settings, +run the checker with `-Repository KofTwentyTwo/standards -LocalPath .`; this requires +authenticated GitHub access and sufficient permissions. + +## Coding Style & Naming Conventions + +Follow `.editorconfig`: UTF-8, LF, final newline, three-space indentation by default, +and two spaces for YAML. Follow language profiles in `standards/coding/` and their +matching `configs/`; PowerShell uses approved verbs and Allman braces. Markdown +follows `.markdownlint-cli2.yaml`, which disables line-length limits. Use descriptive +kebab-case document names, such as `security-program.md`. + +Write requirements as `K22--` with MUST, SHOULD, or MAY, followed by +*Why* and *Verified by*. Never reuse or renumber IDs. Extend enforcement for new MUSTs +and update applicable compliance crosswalks in the same change. + +## Testing Guidelines + +Checker regression tests are built into `-SelfTest`; there is no separate test +directory or repository-wide coverage gate. Extend those tests when changing checker +behavior. Validate documentation with Markdown and internal-link checks; validate +workflow changes with actionlint and zizmor. All required CI checks must pass. + +## Commit & Pull Request Guidelines + +History uses Conventional Commits, such as `feat(ci): ...` and `fix(docs): ...`. +Use topic branches like `docs/clarify-dco`; target `main`. Sign commits and add DCO +sign-off with `git commit -s`. PR titles must also be Conventional Commits. Describe +the change and validation, link relevant issues, and resolve review threads before +squash merging. Discuss requirement changes in an issue first; label incompatible +new MUSTs `breaking`. + +## Security & Agent Instructions + +Keep content original; never copy employer or client documents. Pin external Actions +to full commit SHAs with version comments and images to digests. Follow `SECURITY.md` +for private reports and `policies/ai-assisted-development.md` for agent permissions. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..06f0180 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Release Please maintains this file from Conventional Commits. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a224624..53f6315 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,22 @@ themselves: everything below comes from the [SDLC policy](policies/sdlc.md). ## Running the checks locally +Documentation and package checks: + +```powershell +npx markdownlint-cli2 "**/*.md" +lychee --offline --include-fragments --no-progress './**/*.md' +pwsh tools/Test-RepoConformance.ps1 -SelfTest +pwsh tools/Test-StandardsPackage.ps1 +pwsh tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly +``` + +Package tests use an isolated temporary Git fixture; they do not commit changes in +this repository. For version proposals and release bundles, see +[bundle releases](docs/releases.md). + +Workflow and security checks: + ```powershell zizmor .github/workflows actionlint diff --git a/README.md b/README.md index e233b39..f26244e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ reusable GitHub Actions workflows, repository templates, and a conformance check ## Status -> **Draft, before the first release (`v1.0.0`).** The requirements are written and the +> **Draft standards, before `v1.0.0`.** The requirements are written and the > enforcement tooling exists, but no KofTwentyTwo repository has been verified against > them yet; adoption starts with this repository, then > [AppKit](https://github.com/KofTwentyTwo/AppKit) and @@ -303,6 +303,12 @@ the shared release workflow ([release standard](standards/releases.md)). ## Staying current +Download a versioned ZIP or tar.gz standards bundle from +[GitHub Releases](https://github.com/KofTwentyTwo/standards/releases). Bundles preserve +the repository layout, include checksums and provenance attestations, and become +immutable on publication. See [bundle releases](docs/releases.md) for semantic version +proposals, maintainer setup, publishing, verification, and local reproduction. + - This repository is versioned with [Semantic Versioning](https://semver.org). A new **MUST** that existing repositories do not meet is a MAJOR release; read its release notes before updating. diff --git a/SECURITY.md b/SECURITY.md index 333a90d..68a8e6b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,7 +17,7 @@ conformance check that passes when it should fail. | Fix released | Critical 7 days, High 30 days, Medium 90 days, Low next release, from confirmation | | Public disclosure | coordinated with the reporter; by default 90 days after the report, sooner once fixed | -Until this repository's first release (`v1.0.0`) these timeframes are targets the +Before `v1.0.0` these timeframes are targets the maintainer works to; from `v1.0.0` they are commitments. Fixed vulnerabilities are published as GitHub Security Advisories with a CVE. These diff --git a/docs/adr/0001-release-standards-bundles.md b/docs/adr/0001-release-standards-bundles.md new file mode 100644 index 0000000..b492bd7 --- /dev/null +++ b/docs/adr/0001-release-standards-bundles.md @@ -0,0 +1,47 @@ +# ADR-0001: Release standards as immutable GitHub bundles + +- **Status:** Accepted with the reviewed implementation PR +- **Date:** 2026-10-10 +- **Decider:** Repository maintainer + +## Context + +Consumers need a versioned package of the policies, standards, configs, templates, +and tooling. Content spans several languages and two primary licenses; it has no +application runtime or build. Existing policies require Conventional Commits, +maintainer-created protected tags, gated reusable release workflows, checksums, +attestations, and immutable GitHub Releases. + +## Options considered + +- **GitHub ZIP/tar.gz bundle:** preserves the full repository layout and works for + every consumer with no package-manager dependency. Extraction and copying configs + remain explicit consumer actions. +- **npm or NuGet package:** convenient for one ecosystem, but imposes that ecosystem + on documentation and polyglot tooling. Adds a registry and publishing policy. +- **Automatic tags on every merge:** fewer maintainer steps, but conflicts with the + current tag-creation ruleset and release-checklist process. + +## Decision + +Use Release Please for version/changelog PRs and publish ZIP/tar.gz bundles through +the reusable `release-standards.yml` workflow when the maintainer tags the merged +release PR. Build from the Git commit, attest all assets, upload everything to a +draft, and publish only with repository immutability enabled. + +## Consequences + +- Consumers can pin, verify, and extract a standards snapshot across platforms. +- Hidden configuration and license notices remain part of the package. +- Version proposals pass normal review; maintainers retain tag creation and release + approval. No ruleset bypass or registry publishing secret is added. +- A scoped GitHub App is needed for release PR CI and the settings-read check. Its + key is held in environment secrets; installation tokens are scoped per job and + revoked. Main-only access for the version bot is covered by + [EX-0006](../../exceptions/register.md#ex-0006); publishing remains tag-only. +- Packaging excludes local edits and untracked files. Rebuilds require the same + source commit, version, checklist URL, and compatible Git tooling. +- Complete App/environment setup and the first-release checklist before activating + the pipeline. This ADR is reviewed with the implementation PR. +- Review the [distribution threat model](../security/threat-model.md), including the + version-bot credential boundary and its scoped exception, in the same PR. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..1c5a158 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,128 @@ +# Standards bundle releases + +The official distribution channel is +[GitHub Releases](https://github.com/KofTwentyTwo/standards/releases). Each stable +`vMAJOR.MINOR.PATCH` release contains ZIP and tar.gz archives of the **committed +repository**, with the directory layout intact, including hidden configurations, +licenses, workflows, templates, and tooling. Git metadata, untracked files, and +uncommitted edits are excluded. Extract either archive to use the standards offline +or copy its configs and templates into another repository. + +## Semantic versions + +[Release Please](https://github.com/googleapis/release-please) opens and updates a +release PR after successful `main` CI, changing `version.txt`, `CHANGELOG.md`, and +`.release-please-manifest.json`. The first proposal is `0.1.0`; `0.0.0` is the +unreleased bootstrap placeholder. The tag is the published version's authority; the files are checked +against it before packaging. + +| Commit or squash PR title | Bump | Example | +| --- | --- | --- | +| `fix:`, `docs:`, `security:` | PATCH | `docs(testing): clarify coverage calculation` | +| `feat:` | MINOR | `feat(coding): add a language profile` | +| Any type with `!` or `BREAKING CHANGE:` | MAJOR | `feat(repository)!: require a new control` | + +Use `feat` for new guidance and `!` for new obligations or removed allowances as +defined in [the versioning policy](../policies/README.md#versioning-of-the-standards). +Infrastructure changes appear in release notes and can produce PATCH releases; +`test`, `style`, and `chore` alone do not produce release notes. Review the proposed +version and notes before merging. A `breaking` label alone does not change the version. + +## One-time maintainer setup + +1. Keep **Settings → General → Releases → Enable release immutability** enabled. + The publisher checks the setting before creating a release and refuses to proceed + if it is disabled or unreadable. GitHub locks assets and the tag on publication; + immutability applies to future releases, not retroactively. +2. Keep [EX-0006](../exceptions/register.md#ex-0006) open and unexpired before + enabling version proposals. `K22-CI-16` otherwise restricts remaining secrets to + tag-only environments; the release-PR bot needs a narrow main-only exception. + Approval is recorded in the reviewed release-bundle implementation PR. +3. Create a GitHub App installed **only on this repository**, with Contents and Pull + requests **write**, Administration **read**, and no ruleset bypass. Store its client + ID in the Actions variable `RELEASE_APP_CLIENT_ID`. Short-lived App tokens let + release PRs trigger CI; a PR made using `GITHUB_TOKEN` would not. +4. Configure `release-automation` with deployment access for `main` only, and + `release` with a maintainer reviewer and deployment access for `v*` tags only. + Store `RELEASE_APP_PRIVATE_KEY` separately in these **environment secrets**; + never as a repository secret or committed file. Environments in the reusable + publisher resolve their own secrets; caller workflows do not pass the key. + Keep `protect-main` and `protect-release-tags` intact; the App does not create + tags or bypass review. Allow the SHA-pinned Release Please action if repository + action restrictions require an explicit allowlist entry. + +No npm/NuGet publishing credential or registry package is required. Upstream App-token +and Release Please actions are pinned and tracked by Dependabot. See the +[distribution ADR](adr/0001-release-standards-bundles.md). + +## Publish a version + +1. Review the release PR, complete its CI and review gates, and squash-merge it. +2. For MINOR and MAJOR releases, complete the pre-tag checks in an issue made from + [the release checklist](../templates/release-checklist.md). Attach the full + conformance result and security assessment. PATCH releases still need conformance + and scan results. Keep the README's draft/adoption status accurate before `v1.0.0`. + Apply the maintainer-only `release: ready` label after sections 1–4 and the + green-main/release-notes check in section 5 are complete. Leave publication checks + unchecked until verified, then close the issue after shipping. +3. Tag the **release PR's merge commit** with its proposed version. Only the maintainer + can create protected tags. For a MINOR or MAJOR, the annotation must contain + `Release checklist: https://github.com/KofTwentyTwo/standards/issues/`. + + ```bash + git fetch origin main + git tag -s v0.1.0 \ + -m "Release checklist: https://github.com/KofTwentyTwo/standards/issues/123" + git push origin v0.1.0 + ``` + +4. The tag workflow validates ancestry, version files, the pending release PR, and + the checklist; reruns all repository CI gates; builds and attests the assets; + and waits for the `release` environment approval. Publication creates a draft with + all assets and notes, then publishes it. The release PR is marked + `autorelease: tagged` so the next version can be proposed. + +These bundles ship no compiled software, so the compiled-artifact SBOM requirement +does not apply. The included workflows/configs retain their upstream pins; source +provenance and checksums identify the published bundle. + +## Verify and reproduce + +Every release includes `KofTwentyTwo-standards-.zip`, `.tar.gz`, +`.manifest.json`, `.notes.md`, and `SHA256SUMS`. The manifest records the exact commit +and license split. Download assets from a specific version, then verify: + +```bash +gh release download v0.1.0 --repo KofTwentyTwo/standards --dir standards-0.1.0 +cd standards-0.1.0 +sha256sum --check SHA256SUMS +gh attestation verify KofTwentyTwo-standards-0.1.0.zip --repo KofTwentyTwo/standards +``` + +On Windows, compare `Get-FileHash -Algorithm SHA256` to the corresponding entry in +`SHA256SUMS`. Pin reusable workflow callers to the release's full commit SHA, with a +version comment; do not use a moving branch or tag as a workflow pin. + +To reproduce from a checkout containing the release commit: + +```powershell +pwsh tools/Build-StandardsPackage.ps1 -Version 0.1.0 -Ref v0.1.0 -OutputPath artifacts/rebuild-0.1.0 +pwsh tools/Test-StandardsPackage.ps1 +``` + +Use the original checklist URL with `-ChecklistUrl` to reproduce `.notes.md` and +`SHA256SUMS` exactly. ZIP and tar.gz output is deterministic for a commit using the +same Git/archive tool versions. The builder refuses an existing output directory. + +## Failed releases + +Never replace published assets or move/reuse a version tag. Fix a published error +with a new release. A failed upload or publication leaves a draft; inspect its run +and assets, then remove **only the unpublished draft** before rerunning the tag's +`standards-release` workflow using **Run workflow** with that tag selected. Keep the +tag unchanged. If publication succeeded but PR labeling failed, change the PR label +from `autorelease: pending` to `autorelease: tagged`; do not rebuild the release. + +References: [GitHub immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases), +[enabling immutability](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/establish-provenance-and-integrity/prevent-release-changes), +and [Release Please token behavior](https://github.com/googleapis/release-please-action#other-actions-on-release-please-prs). diff --git a/docs/security/scorecard-dispositions.md b/docs/security/scorecard-dispositions.md new file mode 100644 index 0000000..97b3209 --- /dev/null +++ b/docs/security/scorecard-dispositions.md @@ -0,0 +1,22 @@ +# Scorecard finding dispositions + +Reviewed on 2026-10-10 for the v0.1.0 release. These are repository-process +heuristics, not identified vulnerabilities in shipped code or dependencies. +Static review inspected the reported main revision, current rulesets, the security +policy, and EX-0001. It does not establish an independent human review of historical +changes or predict future maintenance. + +| Alert | Disposition and evidence | Reassess | +| --- | --- | --- | +| [#4 Code-Review](https://github.com/KofTwentyTwo/standards/security/code-scanning/4) | Accepted single-maintainer risk under [EX-0001](../../exceptions/register.md#ex-0001). Outside contributors cannot merge; PR checks, AI review, CodeQL, signed commits, and maintainer merge remain required. Scorecard counts human approvals, so it does not represent these compensating controls. | 2027-01-10, or when a second maintainer joins | +| [#3 Maintained](https://github.com/KofTwentyTwo/standards/security/code-scanning/3) | Age heuristic: GitHub reports creation on 2026-10-09, with recent feature PRs and active CI. The message identifies a repository younger than 90 days, rather than abandoned shipped software. | 2027-01-10 | +| [#1 Branch-Protection](https://github.com/KofTwentyTwo/standards/security/code-scanning/1) | Accepted human-approval gap under EX-0001. Active `protect-main` has no bypass and enforces PR-only squash merges, nine required checks, signed commits, linear history, and resolved threads. Human approval by another maintainer is unavailable. | 2027-01-10, or when a second maintainer joins | + +Keep the accepted risk visible; dismissal is not a claim that the missing second +maintainer exists. Do not disable Scorecard or weaken protections. Reopen and +reassess these dispositions when their assumptions change. The LOW best-practices +badge and MEDIUM fuzzing recommendations remain open for follow-up. + +No OpenVEX statement is fabricated for these process findings: no vulnerable shipped +component or exploit was identified. Any actual vulnerability present but not +exploitable needs the OpenVEX statement required by K22-SEC-42. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md new file mode 100644 index 0000000..8a6d95d --- /dev/null +++ b/docs/security/threat-model.md @@ -0,0 +1,83 @@ +# Threat model: standards distribution + +- **Version reviewed:** v0.1.0 release-bundle implementation +- **Last updated:** 2026-10-10 +- **Status:** Implementation reviewed; provisioning is verified in the release checklist. + +## Product and actors + +This public repository distributes engineering guidance, configurations, templates, +and scripts. Consumers download a versioned bundle or call reusable workflows pinned +to a commit. The bundle does not run an application or collect consumer data. +Contributors submit changes; the maintainer reviews and merges PRs, creates protected +version tags, and approves publication. GitHub-hosted Actions runners build artifacts; +GitHub stores releases, environment secrets, and attestations. Upstream Actions are +third-party code trusted only at reviewed, pinned versions. + +## Data flow and boundaries + +```mermaid +flowchart LR + contributor[Contributor] --> pr[Pull request] + pr --> gates[CI and maintainer review] + gates --> main[Protected main] + main --> proposal[Version and changelog PR] + proposal --> gates + main --> tag[Maintainer-created protected tag] + tag --> build[Reusable gated build] + build --> draft[Draft assets and attestations] + draft --> approval[Release environment approval] + approval --> release[Immutable GitHub Release] + release --> consumer[Consumer verifies and extracts] +``` + +Untrusted PR content cannot access version-bot or publication secrets. The +`release-automation` environment admits only protected `main`; its App key is used +after CI succeeds. The `release` environment admits only protected `v*` tags and +requires maintainer approval. Tokens are scoped per job and revoked on completion. +Publishing checks GitHub's immutability setting before creating the draft. + +## External interfaces and assets + +| Interface | Data and authentication | Validation | +| --- | --- | --- | +| Git checkout and archive | Public source, tagged commit | Main ancestry, committed version/manifest/changelog | +| GitHub REST API | App installation token or job-scoped `GITHUB_TOKEN` over HTTPS | Release PR, maintainer-approved preflight checklist, immutability setting | +| Actions secrets | App private key in named environments | Deployment policy, reviewed job definitions, no repository secret | +| OIDC attestations | GitHub-signed build provenance | Consumer `gh attestation verify` | +| Release downloads | Versioned archives, manifest, notes, checksums | HTTPS, checksum and provenance verification | + +Assets are the App credential, integrity of standards and executable tooling, +protected source history, release tags, artifact bytes, and distribution availability. + +## Threats and controls + +| Threat / STRIDE category | Control | Remaining condition | +| --- | --- | --- | +| Forged or malicious contribution / spoofing | Signed commits, DCO, PR gates and maintainer review | Review remains accountable to a human | +| Modified source or artifact / tampering | Commit-based archives, checksums, attestations, immutable publication | Consumer must verify downloads | +| Disputed release origin / repudiation | Protected tagged commit, manifest SHA, release checklist, attestations | Maintainer reviews checklist evidence | +| Credential theft / information disclosure | Environment restrictions, scoped App installation, ephemeral tokens, secret scans | EX-0006 must remain open and unexpired | +| Failed or duplicate publication / denial of service | Timeouts, per-tag concurrency, draft-first upload, no replacement | Failed unpublished drafts need maintainer recovery | +| Workflow privilege escalation | SHA-pinned Actions, no cached release dependencies, no App ruleset bypass | Compromised trusted action remains a supply-chain risk | + +## Exceptions and review + +[EX-0006](../../exceptions/register.md#ex-0006) covers main-only access to the version +bot's key. Existing single-maintainer review uses +[EX-0001](../../exceptions/register.md#ex-0001). Review this model with the implementation +PR and each MINOR/MAJOR checklist; update it when credentials, channels, or workflow +trust boundaries change. + +## Review log + +- **2026-10-10, v0.1.0:** Reviewed source-only packaging, release-PR validation, + preflight approval, App token scopes, environment restrictions, provenance, and + immutable publication. Main and tag protections remain in place. Most likely + failure is incomplete draft publication; recovery retains the tag and replaces + no published assets. Highest-impact threat is a compromised credential or upstream + Action; restricted environments, scoped installation, reviewed SHA pins, and + required PR gates limit its reach. Provisioning and live checks are recorded in + the first-release checklist. Existing Scorecard process findings are recorded in + [their dispositions](scorecard-dispositions.md); EX-0001 retains the accepted + single-maintainer risk. diff --git a/exceptions/register.md b/exceptions/register.md index 8015cb2..7136da6 100644 --- a/exceptions/register.md +++ b/exceptions/register.md @@ -10,6 +10,7 @@ Open and closed exceptions to the KofTwentyTwo standards. The process is in | [EX-0003](#ex-0003) | Open | K22-DEP-02 (license allowlist) | gclo | Reviewed yearly, next on 2027-10-09 | | [EX-0004](#ex-0004) | Open | K22-CI-30, K22-REL-04 (shared release workflow) | gclo | When the shared reusable release workflow ships, at the latest 2027-04-09 | | [EX-0005](#ex-0005) | Open | K22-AI-10 (agents via PR only), K22-AI-11 (in part: unconfirmed pushes) | second-brain, nix, Praetor, Jellyfin | Reviewed yearly, next on 2027-10-10 | +| [EX-0006](#ex-0006) | Open | K22-CI-16 (tag-only environment secrets) | standards | 2027-04-10; review 2027-01-10 | ## EX-0001 @@ -143,3 +144,28 @@ Open and closed exceptions to the KofTwentyTwo standards. The process is in requests per `K22-AI-10`. - **Expires:** reviewed yearly, next on 2027-10-10. - **Owner:** the maintainer. + +## EX-0006 + +**Main-only release-version bot credential** + +- **Status:** Open; approved with the reviewed release-bundle implementation PR. +- **Requirements:** `K22-CI-16` restricts remaining secrets to environments whose + deployment rules allow only protected `v*` tags. +- **Scope:** `KofTwentyTwo/standards`, only `RELEASE_APP_PRIVATE_KEY` in the + `release-automation` environment for the `ci.yml` release-PR job on protected `main`. +- **Reason:** Release Please needs a GitHub App token to create version PRs that + trigger CI without enabling PR creation by `GITHUB_TOKEN`. No native GitHub App + OIDC exchange is configured; the private key is therefore needed before a tag exists. +- **Risk:** compromised version-PR automation could obtain the App key and exercise + the App's repository permissions. +- **Compensating controls:** App installation limited to this repository; Contents and + Pull requests write, Administration read only; no ruleset bypass; main-only + environment deployment policy; job runs only after all main CI gates succeed; + SHA-pinned actions; short-lived scoped installation tokens revoked after each job; + all resulting PRs still require normal checks and maintainer review. The bot neither + creates release tags nor signs or publishes packages. Publication still uses the + protected tag-only `release` environment and GitHub's immutable-releases setting. +- **Expires:** 2027-04-10, or earlier when version proposals use credential federation. +- **Owner:** the maintainer. +- **Review date:** 2027-01-10. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..64c098f --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": false, + "initial-version": "0.1.0", + "changelog-sections": [ + { "type": "security", "section": "Security", "hidden": false }, + { "type": "feat", "section": "Features", "hidden": false }, + { "type": "fix", "section": "Bug Fixes", "hidden": false }, + { "type": "docs", "section": "Documentation", "hidden": false }, + { "type": "perf", "section": "Performance", "hidden": false }, + { "type": "refactor", "section": "Refactoring", "hidden": false }, + { "type": "build", "section": "Build", "hidden": false }, + { "type": "ci", "section": "Automation", "hidden": false }, + { "type": "revert", "section": "Reverts", "hidden": false } + ], + "packages": { + ".": { "version-file": "version.txt" } + } +} diff --git a/tools/Build-StandardsPackage.ps1 b/tools/Build-StandardsPackage.ps1 new file mode 100644 index 0000000..8660577 --- /dev/null +++ b/tools/Build-StandardsPackage.ps1 @@ -0,0 +1,98 @@ +#Requires -Version 7.4 +<# +.SYNOPSIS +Build versioned standards archives from a Git commit. +.DESCRIPTION +Uses git archive so untracked files and local edits cannot enter a release. Writes +ZIP, tar.gz, source manifest, release notes, and SHA256SUMS without overwriting output. +.PARAMETER Version +Stable semantic version matching the committed version.txt and release manifest. +.PARAMETER RepositoryPath +Local source repository; defaults to the parent directory of this script. +.PARAMETER Ref +Commit to package; defaults to HEAD. +.PARAMETER OutputPath +New output directory; defaults to artifacts/standards in the source repository. +.PARAMETER ChecklistUrl +Completed release checklist to link in the notes, when applicable. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$')] + [string] $Version, + [string] $RepositoryPath = (Split-Path -Parent $PSScriptRoot), + [ValidateNotNullOrEmpty()] + [string] $Ref = 'HEAD', + [string] $OutputPath, + [AllowEmptyString()] + [string] $ChecklistUrl = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +$repository = (Resolve-Path -LiteralPath $RepositoryPath).Path +$commit = (git -C $repository rev-parse --verify "$Ref^{commit}").Trim() +$committedVersion = (git -C $repository show "${commit}:version.txt").Trim() +$releaseManifest = (git -C $repository show "${commit}:.release-please-manifest.json") | ConvertFrom-Json -AsHashtable +if($committedVersion -ne $Version -or $releaseManifest['.'] -ne $Version) +{ + throw "Version $Version does not match version.txt and the release manifest at $commit." +} + +$changelog = (git -C $repository show "${commit}:CHANGELOG.md") -join "`n" +$escapedVersion = [regex]::Escape($Version) +$section = [regex]::Match($changelog, "(?ms)^## (?:\[$escapedVersion\](?:\([^\r\n]*\))?|$escapedVersion)(?=\s|$)[^\r\n]*\r?\n.*?(?=^## |\z)") +if(-not $section.Success) +{ + throw "CHANGELOG.md has no release notes for $Version at $commit." +} +$notes = $section.Value.Trim() +if($ChecklistUrl) +{ + if($ChecklistUrl -notmatch '^https://github\.com/[^/]+/[^/]+/issues/[1-9][0-9]*$') + { + throw 'ChecklistUrl must be a GitHub issue URL.' + } + $notes += "`n`nRelease checklist: $ChecklistUrl" +} + +if(-not $OutputPath) +{ + $OutputPath = Join-Path $repository 'artifacts/standards' +} +$outputDirectory = [System.IO.Path]::GetFullPath($OutputPath) +if(Test-Path -LiteralPath $outputDirectory) +{ + throw "Output directory already exists: $outputDirectory. Choose a new directory." +} +New-Item -ItemType Directory -Path $outputDirectory | Out-Null + +$name = "KofTwentyTwo-standards-$Version" +foreach($format in @('zip', 'tar.gz')) +{ + $archive = Join-Path $outputDirectory "$name.$format" + git -C $repository archive "--format=$format" "--prefix=$name/" "--output=$archive" $commit +} + +$manifest = [ordered]@{ + name = 'KofTwentyTwo/standards' + version = $Version + tag = "v$Version" + commit = $commit + repository = 'https://github.com/KofTwentyTwo/standards' + licenses = @('CC-BY-4.0 (documentation)', 'MIT (tooling)', 'Apache-2.0 (Kingsrook files; see NOTICE)') +} +$utf8 = [System.Text.UTF8Encoding]::new($false) +$manifestText = ($manifest | ConvertTo-Json -Depth 4).Replace("`r`n", "`n") + "`n" +[System.IO.File]::WriteAllText((Join-Path $outputDirectory "$name.manifest.json"), $manifestText, $utf8) +[System.IO.File]::WriteAllText((Join-Path $outputDirectory "$name.notes.md"), $notes.Replace("`r`n", "`n") + "`n", $utf8) +$checksums = foreach($file in (Get-ChildItem -LiteralPath $outputDirectory -File | Sort-Object -Property Name)) +{ + $hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $($file.Name)" +} +[System.IO.File]::WriteAllText((Join-Path $outputDirectory 'SHA256SUMS'), ($checksums -join "`n") + "`n", $utf8) +Write-Output "Built $name from $commit in $outputDirectory" diff --git a/tools/Test-StandardsPackage.ps1 b/tools/Test-StandardsPackage.ps1 new file mode 100644 index 0000000..0b9334b --- /dev/null +++ b/tools/Test-StandardsPackage.ps1 @@ -0,0 +1,122 @@ +#Requires -Version 7.4 +<# +.SYNOPSIS +Exercise standards packaging with an isolated local Git fixture. +.DESCRIPTION +Checks committed content, hidden files, both archive formats, determinism, hashes, +version validation, release notes, and refusal to overwrite existing output. +#> +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +<# +.SYNOPSIS +Fail a behavioral assertion with a descriptive message. +#> +function Test-Condition +{ + param([bool] $Condition, [string] $Message) + if(-not $Condition) { throw "FAIL: $Message" } + Write-Output "PASS: $Message" +} + + + +<# +.SYNOPSIS +Verify an operation refuses invalid input for the expected reason. +#> +function Test-Exception +{ + param([scriptblock] $Operation, [string] $Pattern) + $failure = $null + try { & $Operation | Out-Null } + catch { $failure = $_.Exception.Message } + Test-Condition ($null -ne $failure -and $failure -match $Pattern) "rejects $Pattern" +} + +$builder = Join-Path $PSScriptRoot 'Build-StandardsPackage.ps1' +$fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) "standards-package-$([guid]::NewGuid().ToString('N'))" +$source = Join-Path $fixtureRoot 'source' +New-Item -ItemType Directory -Path $source | Out-Null +git init --quiet $source +git -C $source config core.autocrlf false +git -C $source config core.hooksPath /dev/null +Set-Content -LiteralPath (Join-Path $source 'version.txt') -Value '1.2.3' +Set-Content -LiteralPath (Join-Path $source '.release-please-manifest.json') -Value '{".":"1.2.3"}' +Set-Content -LiteralPath (Join-Path $source '.editorconfig') -Value 'root = true' +Set-Content -LiteralPath (Join-Path $source 'README.md') -Value 'committed content' +Set-Content -LiteralPath (Join-Path $source 'CHANGELOG.md') -Value @' +# Changelog + +## [1.2.4](https://example.com/next) (2026-10-11) + +Future notes. + +## [1.2.3](https://example.com/current) (2026-10-10) + +### Security + +Current notes. + +## 1.2.2 + +Older notes. +'@ +git -C $source add -- . +git -C $source -c user.name=Fixture -c user.email=fixture@example.invalid -c commit.gpgsign=false commit --quiet -m 'test: packaging fixture' + +Set-Content -LiteralPath (Join-Path $source 'README.md') -Value 'uncommitted content' +Set-Content -LiteralPath (Join-Path $source 'local-only.txt') -Value 'untracked content' +$first = Join-Path $fixtureRoot 'first' +$second = Join-Path $fixtureRoot 'second' +$checklist = 'https://github.com/KofTwentyTwo/standards/issues/123' +& $builder -Version '1.2.3' -RepositoryPath $source -OutputPath $first -ChecklistUrl $checklist +& $builder -Version '1.2.3' -RepositoryPath $source -OutputPath $second -ChecklistUrl $checklist + +$prefix = 'KofTwentyTwo-standards-1.2.3' +$zip = [System.IO.Compression.ZipFile]::OpenRead((Join-Path $first "$prefix.zip")) +try +{ + $names = @($zip.Entries.FullName) + Test-Condition ($names -contains "$prefix/.editorconfig") 'ZIP preserves hidden configuration files' + Test-Condition (-not ($names -match '/(?:\.git/|local-only\.txt$)')) 'ZIP excludes Git metadata and untracked files' + $reader = [System.IO.StreamReader]::new($zip.GetEntry("$prefix/README.md").Open()) + try { Test-Condition ($reader.ReadToEnd().Trim() -eq 'committed content') 'ZIP contains committed content despite local edits' } + finally { $reader.Dispose() } +} +finally { $zip.Dispose() } + +$extracted = Join-Path $fixtureRoot 'extracted' +New-Item -ItemType Directory -Path $extracted | Out-Null +tar -xzf (Join-Path $first "$prefix.tar.gz") -C $extracted +Test-Condition ((Get-Content -LiteralPath (Join-Path $extracted "$prefix/README.md") -Raw).Trim() -eq 'committed content') 'tar.gz contains committed content' +Test-Condition (Test-Path -LiteralPath (Join-Path $extracted "$prefix/.editorconfig")) 'tar.gz preserves hidden configuration files' +$manifest = Get-Content -LiteralPath (Join-Path $first "$prefix.manifest.json") -Raw | ConvertFrom-Json +Test-Condition ($manifest.version -eq '1.2.3' -and $manifest.commit -eq (git -C $source rev-parse HEAD)) 'manifest identifies version and exact source commit' +$notes = Get-Content -LiteralPath (Join-Path $first "$prefix.notes.md") -Raw +Test-Condition ($notes -match 'Current notes' -and $notes -notmatch 'Older notes|Future notes' -and $notes.Contains($checklist)) 'notes contain only this release and its checklist' +foreach($file in (Get-ChildItem -LiteralPath $first -File)) +{ + Test-Condition ((Get-FileHash -LiteralPath $file.FullName).Hash -eq (Get-FileHash -LiteralPath (Join-Path $second $file.Name)).Hash) "$($file.Name) is reproducible" +} +$checksums = @(Get-Content -LiteralPath (Join-Path $first 'SHA256SUMS')) +Test-Condition ($checksums.Count -eq 4) 'checksums cover every asset except the checksum file itself' +foreach($line in $checksums) +{ + $hash, $fileName = $line -split ' ', 2 + Test-Condition ($hash -eq (Get-FileHash -LiteralPath (Join-Path $first $fileName)).Hash.ToLowerInvariant()) "$fileName matches its checksum" +} +Test-Exception { & $builder -Version '1.2.4' -RepositoryPath $source -OutputPath (Join-Path $fixtureRoot 'mismatch') } 'does not match' +Test-Exception { & $builder -Version '01.2.3' -RepositoryPath $source } 'ValidatePattern|validation|pattern' +Test-Exception { & $builder -Version '1.2.3' -RepositoryPath $source -OutputPath $first } 'already exists' +Test-Exception { & $builder -Version '1.2.3' -RepositoryPath $source -OutputPath (Join-Path $fixtureRoot 'invalid-url') -ChecklistUrl 'http://example.com/123' } 'GitHub issue URL' +Set-Content -LiteralPath (Join-Path $source 'CHANGELOG.md') -Value '# Changelog without release notes' +git -C $source add -- CHANGELOG.md +git -C $source -c user.name=Fixture -c user.email=fixture@example.invalid -c commit.gpgsign=false commit --quiet -m 'test: missing release notes fixture' +Test-Exception { & $builder -Version '1.2.3' -RepositoryPath $source -OutputPath (Join-Path $fixtureRoot 'missing-notes') } 'no release notes' +Write-Output "All standards package tests passed. Fixture: $fixtureRoot" diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..77d6f4c --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.0.0 diff --git a/zizmor.yml b/zizmor.yml index c1ec7e5..f6de413 100644 --- a/zizmor.yml +++ b/zizmor.yml @@ -7,3 +7,5 @@ rules: # not yet understood by actionlint 1.7.12, which is also a required gate. ignore: - ci.yml + - release.yml + - release-standards.yml