Skip to content

Commit 1ac3201

Browse files
📖 [Docs]: A dependency pinning standard, and its PowerShell version-constraint implementation (#26)
The coding standards now include a **Dependencies** standard: how to pin a dependency and keep it current, across every ecosystem. It lays out the locking spectrum — from identity pins through patch, minor, and major to floating latest — and the balance every pin has to strike: loose enough to take security patches and fixes quickly, tight enough that unvetted or compromised code never lands automatically. PowerShell version constraints and GitHub Actions SHA pinning are its concrete implementations. ## New: the Dependencies standard `Coding Standards → Dependencies` separates a pin into two axes — **identity** (which artifact, proven: a module `GUID`, an Action SHA, an image digest) and **version tightness** (exact through latest) — and walks the spectrum with the tradeoff at each step: tighter is safer but slower to update; looser is faster but less vetted and not reproducible. It states the resolution the ecosystem follows — pin for integrity and automate the movement — and answers the practical questions: which update tracks exist, who each is for, and that a repository does not need all of them. A library declares a wide compatibility floor so it does not over-constrain its consumers; an application pins tight for reproducibility and lets the updater move it. It links to Security → Supply chain (the why) and the Dependency Updates capability (the automation). ## New: PowerShell version constraints `Coding Standards → PowerShell → Version Constraints` is the PowerShell implementation. Express every constraint as a NuGet version range in explicit brackets; a new locking-spectrum table maps identity, exact, patch, minor, major, and latest to NuGet ranges and `#Requires` keys. The syntax applies directly on PSResourceGet (`Install`/`Find`/`Save`/`Update-PSResource -Version`, `RequiredResourceFile`) and .NET `PackageReference`, maps to `ModuleVersion` / `MaximumVersion` / `RequiredVersion` for `#Requires` and manifests (which cannot take a NuGet string), and flags the PSResourceGet trap where a bare version is treated as the exact version, not a minimum. ## Changed: GitHub Actions The "Pin every action to a full commit SHA" section now names SHA pinning as the GitHub Actions expression of the Dependencies standard — an identity pin, kept current by the updater. ## Technical Details - New `src/docs/Coding-Standards/Dependencies.md` (baseline tier, placed after Security); reframed `src/docs/Coding-Standards/PowerShell/Version-Constraints.md` (adds the locking-spectrum table, folds the old intent→ModuleSpecification table into it); one cross-link added to `src/docs/Coding-Standards/GitHub-Actions.md`; registered in `src/zensical.toml` nav and the generated index tables. - Complements the in-flight `#Requires` PRs #22 (major-version bounding) and #23 (the `#Requires -Modules` reference); the spectrum's major lock (`ModuleVersion='6.0.0'; MaximumVersion='6.*'`) matches them. - Validated locally: `Update-DocumentationIndex.ps1 -Check`, `Test-DocumentationLink.ps1`, and markdownlint (repo config) all pass; front matter parses as YAML. <details> <summary>Related issues</summary> No linked issue — created on direct request. The Dependencies standard is the "separate doc" for the broader dependency/supply-chain update-management concept; open a tracking issue if it should be tracked. </details>
1 parent fcf92a1 commit 1ac3201

6 files changed

Lines changed: 182 additions & 1 deletion

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
title: Dependencies
3+
description: How dependencies are pinned and kept current — the locking spectrum from identity pins to floating latest, and the balance between update velocity and supply-chain exposure.
4+
---
5+
6+
# Dependencies
7+
8+
Every dependency we consume — a PowerShell module, a GitHub Action, a container base image, a .NET package, a Terraform provider — is both a convenience and part of our [attack surface](Security.md#supply-chain). How we depend on one is a single decision made twice: **how tightly to pin it** (how much version drift we accept) and **how it moves forward** (how new versions reach us). Get the balance wrong in either direction and it costs us.
9+
10+
This is the ecosystem-agnostic standard; the per-tool standards apply it. [PowerShell → Version Constraints](PowerShell/Version-Constraints.md) expresses it for modules and packages, and [GitHub Actions → Pin every action to a full commit SHA](GitHub-Actions.md#pin-every-action-to-a-full-commit-sha) expresses it for Actions and images. The [Dependency Updates](../Capabilities/dependency-updates/index.md) capability is the automation that keeps pins current.
11+
12+
## Two decisions, two axes
13+
14+
A pin has two independent parts; keep them separate.
15+
16+
- **Identity** — *which* artifact, proven. A name alone can be squatted, re-tagged, or repointed at new code, so an **identity pin** binds to immutable bytes: a module `GUID`, an Action or commit **SHA**, an image **digest**. It answers "is this the exact thing I vetted?" and is orthogonal to the version.
17+
- **Version tightness** — *which versions* of that artifact are acceptable, from an exact pin to floating latest.
18+
19+
The strongest posture combines both: a verified identity **and** a deliberate version. Identity is the integrity control; tightness is the velocity-versus-risk control below.
20+
21+
## The locking spectrum
22+
23+
From tightest to loosest, each step trades safety for speed:
24+
25+
| Lock | What can change | Update velocity | Supply-chain exposure | Reproducible |
26+
| --- | --- | --- | --- | --- |
27+
| **Identity + exact** (`GUID` / SHA / digest, exact version) | Nothing until you re-pin | Only via a reviewed re-pin PR | **Lowest** — nothing lands unvetted | Yes |
28+
| **Patch** (`x.y.*`) | Fix-level releases | Fixes flow in | Low | With a lockfile |
29+
| **Minor** (`x.*`) | Additive features and fixes | Features and fixes flow in | Moderate | With a lockfile |
30+
| **Major** (floor only, `>= x`) | Anything from the floor up, including breaking releases | Everything flows in | Higher | With a lockfile |
31+
| **Latest / floating** (unpinned, `*`, a moving tag) | Anything, immediately | Immediate | **Highest** — newest code runs before anyone sees it | No |
32+
33+
## The balance
34+
35+
Both ends are a risk; the standard is to avoid living at either extreme.
36+
37+
- **Too loose** (toward latest) maximizes velocity but hands control to the upstream. A newly published version — including a **compromised** one — runs before anyone reviews it, and the build stops being reproducible because two runs resolve different code. This is the classic supply-chain attack path: a malicious release, or a taken-over package, that lands automatically because nothing gated it.
38+
- **Too tight** (a bare exact pin, never moved) maximizes control but rots. The dependency keeps shipping bug and **security** fixes you never take; a disclosed advisory turns yesterday's safe pin into today's vulnerability, and now the exact pin is the very thing stopping you from patching fast enough.
39+
40+
The resolution is not to pick a point and freeze — it is to **pin for integrity and automate the movement**:
41+
42+
1. **Pin tightly** — an identity pin plus a deliberate version, or a lockfile — so every build is reproducible and nothing changes unvetted.
43+
2. **Automate updates** so currency never depends on a human watching upstream: the [Dependency Updates](../Capabilities/dependency-updates/index.md) bot opens one reviewed pull request per bump.
44+
3. **Gate every update through CI and review** — patch and minor updates auto-merge on green checks; **major** updates require a human; security advisories are raised out of band and prioritized.
45+
46+
Tight pinning is safe *because* the updates are automated: the bot closes the currency gap and CI plus review close the vetting gap. You get the reproducibility of an exact pin **and** the patch velocity of a loose range, without the unvetted drift of either.
47+
48+
## Update tracks — who each is for, and whether you need it
49+
50+
A "track" is how a given dependency is allowed to move. You do **not** need every track in every repository; choose one per dependency from what the source is, what it can break, and where you sit.
51+
52+
| Track | Fits a dependency that… | Typical handling |
53+
| --- | --- | --- |
54+
| **Identity + exact** | runs with privilege or has a wide blast radius (Actions, base images), or must be byte-for-byte reproducible | the bot still opens the re-pin PR, but a human reviews every one — never auto-merged |
55+
| **Patch** | is trusted and whose patches are fixes you always want (most dependencies) | auto-merge on green CI |
56+
| **Minor** | is trusted and whose additive releases are safe to absorb | auto-merge on green CI (a repo may require review) |
57+
| **Major** | you actively co-evolve with and can absorb breaking changes for | always human-reviewed |
58+
| **Latest / floating** | is throwaway — an ephemeral local experiment, never shipped or run in CI | not for shipped or CI-run code |
59+
60+
Two questions decide the mix:
61+
62+
- **Are you a library or an application?** A **library** — a module or Action others consume — declares the *widest range it is compatible with* (a floor, rarely a ceiling) so it does not over-constrain its consumers. An **application or end artifact** — a workflow, a deployable, a CI pipeline — pins to *exact resolved versions* for reproducibility and relies on the updater to move them. The same dependency is pinned differently depending on who depends on it.
63+
- **How much do you trust the source, and how large is the blast radius?** The less you trust it, or the more damage a bad version could do, the further toward identity + exact you sit — and the more you lean on automation to stay current, so tightness never becomes staleness.
64+
65+
The healthy default across the ecosystem: **identity-pin what runs (SHAs, digests), floor-declare what you are a library for, lockfile-pin what you ship, and let the [updater](../Capabilities/dependency-updates/index.md) auto-merge patch and minor while a human reviews major.** Reach for a bare exact pin only when reproducibility genuinely demands it, and for floating latest almost never.
66+
67+
## Where this is implemented
68+
69+
- [PowerShell → Version Constraints](PowerShell/Version-Constraints.md) — the spectrum in NuGet version-range syntax and `#Requires` module specifications, including the module `GUID` identity pin.
70+
- [GitHub Actions → Pin every action to a full commit SHA](GitHub-Actions.md#pin-every-action-to-a-full-commit-sha) and [Keep pinned actions current](GitHub-Actions.md#keep-pinned-actions-current) — identity pinning by commit SHA, kept current by the updater.
71+
- [Security → Supply chain](Security.md#supply-chain) — why dependencies are attack surface.
72+
- [Dependency Updates](../Capabilities/dependency-updates/index.md) — the automation that opens, labels, and routes the update pull requests.

‎src/docs/Coding-Standards/GitHub-Actions.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ canonical "how to author" reference that the security control points to.
1717

1818
## Pin every action to a full commit SHA
1919

20+
Pinning an action by SHA is the GitHub Actions expression of the [Dependencies](Dependencies.md) standard — an **identity pin** to immutable bytes, kept current by automation (below).
21+
2022
A `uses:` reference accepts a tag, a branch, or a commit SHA. Tags and branches
2123
are **mutable** — a maintainer (or an attacker who compromises one) can move
2224
them to point at different code. A full commit SHA is **immutable**.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
---
2+
title: Version Constraints
3+
description: "Express module and package version constraints as NuGet version ranges — the canonical notation across PSResourceGet, .NET package references, and (mapped) #Requires and module manifests."
4+
---
5+
6+
# Version Constraints
7+
8+
A version constraint says *which versions of a dependency are acceptable*. PowerShell offers several places to state one — an `Install-PSResource` call, a `#Requires` line, a module manifest, a `.csproj` — and they do not all take the same string. Rather than learn a different dialect for each, express every constraint in **one** notation: the [NuGet version range](https://learn.microsoft.com/nuget/concepts/package-versioning#version-ranges). It is the native syntax of the install tooling ([PSResourceGet](https://learn.microsoft.com/powershell/module/microsoft.powershell.psresourceget/)) and of .NET package references, it follows [SemVer](https://semver.org/), and it makes intent — a floor, a ceiling, an exact pin, a range — explicit and reviewable.
9+
10+
This page is the PowerShell implementation of the [Dependencies](../Dependencies.md) standard: the locking spectrum and the update-velocity-versus-supply-chain-risk balance are argued there; this page is how you express each point of that spectrum in PowerShell. It sits under the [PowerShell standard](index.md), builds on [Security → Supply chain](../Security.md#supply-chain), and pairs with [Dependency Updates](../../Capabilities/dependency-updates/index.md), which keeps pins current.
11+
12+
## The rule
13+
14+
- **State every module or package version constraint as a NuGet version range, in explicit interval brackets** — `[6.0.0, )`, `[6.0.0, 7.0.0)`, `[6.0.0]` — never a bare number.
15+
- **Never write a bare version as a constraint.** A bare `6.0.0` means different things on different surfaces — a *minimum* to .NET package references, but the *exact* required version to PSResourceGet (see [the bare-version trap](#the-bare-version-trap)) — so the same string silently changes meaning. Brackets say one thing everywhere.
16+
- **Choose the lock deliberately** — exact, patch, minor, major, or latest — from the [locking spectrum](#the-locking-spectrum-in-powershell); tighter is safer but slower to update, looser is faster but less vetted. The [Dependencies](../Dependencies.md) standard argues the balance.
17+
- **Declare a compatibility floor in a module** (`ModuleVersion`, `[6.0.0, )`) so you do not over-constrain your consumers; **pin tight what an application or CI installs** for reproducibility, and let the updater move it. Reserve the exact pin for strict reproducibility, not as a way to avoid updates.
18+
- **Keep pins current** with automated update pull requests — see [Dependency Updates](../../Capabilities/dependency-updates/index.md).
19+
20+
## NuGet version-range syntax
21+
22+
The interval notation, from the [NuGet reference](https://learn.microsoft.com/nuget/concepts/package-versioning#version-ranges):
23+
24+
| Range | Meaning | Notes |
25+
| --- | --- | --- |
26+
| `[6.0.0, )` | `x ≥ 6.0.0` | minimum, inclusive — **the default** |
27+
| `(6.0.0, )` | `x > 6.0.0` | minimum, exclusive |
28+
| `[6.0.0]` | `x = 6.0.0` | exact pin |
29+
| `(, 6.0.0]` | `x ≤ 6.0.0` | maximum, inclusive |
30+
| `(, 6.0.0)` | `x < 6.0.0` | maximum, exclusive |
31+
| `[6.0.0, 7.0.0)` | `6.0.0 ≤ x < 7.0.0` | **major lock** — any `6.x` |
32+
| `[6.0.0, 7.0.0]` | `6.0.0 ≤ x ≤ 7.0.0` | closed range |
33+
| `(6.0.0, 7.0.0)` | `6.0.0 < x < 7.0.0` | open range |
34+
35+
A square bracket `[ ]` includes the endpoint; a parenthesis `( )` excludes it. Follow the NuGet guidance on ceilings: do not cap a dependency you do not own unless you know of an incompatibility — an unnecessary upper bound holds consumers back from fixes — but *do* bound a dependency whose next major would break you. A test framework such as Pester is the typical case.
36+
37+
## The locking spectrum in PowerShell
38+
39+
PowerShell can express every point of the [locking spectrum](../Dependencies.md#the-locking-spectrum). The module `GUID` is the **identity pin** — orthogonal to the version, it binds to the exact module and combines with any row below; the rest set version tightness, tightest first:
40+
41+
| Lock | NuGet range (PSResourceGet, `PackageReference`) | `#Requires` / `RequiredModules` |
42+
| --- | --- | --- |
43+
| **Identity** | not a version — pins *which* module | add `GUID = '<module-guid>'` to any row |
44+
| **Exact** | `[6.1.2]` | `RequiredVersion = '6.1.2'` |
45+
| **Patch** (any `6.1.x`) | `[6.1.0, 6.2.0)` | `ModuleVersion = '6.1.0'; MaximumVersion = '6.1.*'` |
46+
| **Minor** (any `6.x`) | `[6.0.0, 7.0.0)` | `ModuleVersion = '6.0.0'; MaximumVersion = '6.*'` |
47+
| **Major** (floor, `≥ 6.0.0`) | `[6.0.0, )` | `ModuleVersion = '6.0.0'` |
48+
| **Latest** | omit `-Version`; `*` in `PackageReference` | a bare name, `@{ ModuleName = 'Pester' }` |
49+
50+
- **Identity + exact** is the tightest — the pin never drifts; the bot proposes each re-pin and a human reviews it. Pair the `GUID` with `RequiredVersion`, or with a lockfile-resolved install, for the strongest supply-chain posture.
51+
- **Patch** and **minor** are the everyday tracks: let fixes (and, for minor, additive features) flow while a new major is held back.
52+
- **Major** — a floor with no ceiling — accepts breaking releases; use it where you actively co-evolve with the dependency, and expect the [updater](../../Capabilities/dependency-updates/index.md) to route those as human-reviewed `update:major` pull requests.
53+
- **Latest** pulls whatever is newest, unvetted and non-reproducible — avoid it for anything shipped or run in CI (see [Dependencies → the balance](../Dependencies.md#the-balance)).
54+
55+
## Where the range applies directly
56+
57+
### Installing and restoring — PSResourceGet
58+
59+
`Install-PSResource`, `Find-PSResource`, `Save-PSResource`, and `Update-PSResource` take the range on `-Version`, as does the `version` field of a `-RequiredResource` hashtable or a `-RequiredResourceFile` manifest:
60+
61+
```powershell
62+
# any 6.x — the standard shape for a tool that breaks across majors
63+
Install-PSResource -Name Pester -Version '[6.0.0, 7.0.0)' -TrustRepository
64+
65+
# floor only — highest available >= 8.0.0
66+
Install-PSResource -Name Az -Version '[8.0.0, )' -TrustRepository
67+
```
68+
69+
A restore manifest states the same range per resource:
70+
71+
```powershell
72+
@{
73+
Pester = @{ version = '[6.0.0, 7.0.0)'; repository = 'PSGallery' }
74+
PSScriptAnalyzer = @{ version = '[1.22.0, )'; repository = 'PSGallery' }
75+
}
76+
```
77+
78+
#### The bare-version trap
79+
80+
PSResourceGet diverges from NuGet on one point: a **bare version is treated as the exact required version, not a minimum**. `Install-PSResource -Name Pester -Version '6.0.0'` installs *only* `6.0.0` — it will not accept `6.0.1`. To get a minimum you must write the range, `-Version '[6.0.0, )'`. This is exactly why the rule forbids bare versions: always use brackets, and the constraint reads the same on every surface.
81+
82+
### Building — .NET package references
83+
84+
A binary module, or a build or test project, references NuGet packages with the same range syntax in `PackageReference`:
85+
86+
```xml
87+
<PackageReference Include="YamlDotNet" Version="[16.0.0, 17.0.0)" />
88+
```
89+
90+
Here a bare `Version="16.0.0"` is a *minimum* — NuGet's own semantics — the mirror image of the PSResourceGet trap: the very same string means "minimum" to the build and "exact" to the installer. Brackets remove the ambiguity.
91+
92+
## `#Requires` and module manifests
93+
94+
`#Requires -Modules` and a manifest's `RequiredModules` do **not** accept a NuGet range string. They take a [module-specification hashtable](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_requires) whose keys are typed: `ModuleVersion` (minimum) and `RequiredVersion` (exact) parse as `[version]`, while `MaximumVersion` is a string that also accepts the `N.*` wildcard. The [spectrum table](#the-locking-spectrum-in-powershell) gives the keys for each lock, and [Module Requirements](Requires-Modules.md) is the full reference with an executable proof; the rules that constrain them:
95+
96+
- `RequiredVersion` cannot be combined with `ModuleVersion` or `MaximumVersion`. A range needs the floor **and** ceiling keys together; the exact pin stands alone.
97+
- `MaximumVersion` is **inclusive** and understands the `N.*` wildcard, so a major lock is `MaximumVersion = '6.*'` — every `6.x`, nothing in `7` — with no `6.999.999` sentinel.
98+
- The module `GUID` pins **identity**, orthogonal to the version: a supply-chain control, not part of the version constraint (see [Security → Supply chain](../Security.md#supply-chain)). Add it to any lock.
99+
- The `#Requires -Version` statement and a manifest's `PowerShellVersion` are **not** package ranges — each is a single minimum engine version (we target PowerShell 7). Leave them as a bare version: `#Requires -Version 7.0`.
100+
101+
## Not a version range — pin by digest
102+
103+
Ranges are for packages resolved from a gallery. Dependencies that are not gallery packages are pinned differently: **GitHub Actions and container images pin to an immutable commit SHA or image digest** — an [identity pin](../Dependencies.md#two-decisions-two-axes) — never a moving tag or a range. See [Security → Supply chain](../Security.md#supply-chain) and [GitHub Actions](../GitHub-Actions.md).

0 commit comments

Comments
 (0)