Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/publish-a-dependency-inventory-with-each-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@cosyte/astm": patch
---

Publish a machine-readable dependency inventory with each release.

Every published version now carries a CycloneDX inventory of what that build was made from, attached
to its GitHub release as a downloadable asset. The inventory names this package and the version
being built alongside every dependency that build resolved, runtime and build-time alike,
transitively, each with the exact version it resolved to. A team evaluating this package for a
regulated laboratory can now read what it is built from and feed it to their own tooling, instead of
taking a sentence about dependencies on trust or reproducing an install to find out.

It is produced and checked by the repository itself, from the checkout, with no registry credential.
The check runs before the registry upload: a version whose inventory cannot be produced, or which
does not truthfully describe the build, fails the release rather than publishing with nothing to
describe it.

What the inventory does not claim. It states what was resolved and nothing about whether that is
safe: it is not a vulnerability scan, a licence assessment, an attestation or a signature, and npm
provenance remains the separate thing it already was. It carries component names and versions only.

No exported value, type or behaviour changes, and no byte is emitted differently.
76 changes: 76 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ name: Release
# Thin caller of the shared release pipeline: Changesets -> npm publish (provenance auto-enables when
# this repo is public) -> docs artifacts -> GitHub release -> repository_dispatch to cosyte/docs.
# Requires the NPM_TOKEN and DOCS_REPO_DISPATCH_TOKEN secrets (passed via `secrets: inherit`).
#
# The second job attaches this package's dependency inventory to that release. It is a job HERE
# rather than a step there because the shared pipeline attaches a fixed two-name asset list
# (docs-content.tar.gz, source.tar.gz) and a third file in dist-artifacts/ is not picked up, so an
# asset this repo wants on its own release page has to be uploaded by this repo.

on:
push:
Expand All @@ -23,3 +28,74 @@ jobs:
with:
package-name: "@cosyte/astm"
secrets: inherit

# Put the dependency inventory on the release page as its own downloadable asset.
#
# WHY IT RUNS AFTER THE PUBLISH, which is the half that decides what a bad inventory costs. The
# GitHub release this uploads to does not exist until the pipeline above has published and cut
# it, so nothing here can run earlier. That is exactly why this job is NOT where the inventory is
# guarded: `prepublishOnly` in package.json runs `pnpm check:dependency-inventory` BEFORE the
# registry upload, so an inventory that cannot be produced or is not true of the build aborts the
# publish and the version never becomes public. This job can only fail a run, and a failed run is
# recoverable; a published version nothing describes is not.
#
# The pipeline above declares no `workflow_call` outputs, so the release is found from the repo
# rather than read from it: the tag Changesets wrote for this version, at this commit.
attach-dependency-inventory:
needs: release
runs-on: ubuntu-latest
permissions:
contents: write # upload an asset to the release the shared pipeline cut
steps:
# Actions are pinned to a commit rather than to a tag that can move.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0 # tags are how this job finds the release; a shallow clone carries none
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# Produced fresh from this commit, and refused unless it is true of this build. Generation is
# deterministic (no timestamp, no serial number), so this is the artifact prepublishOnly
# already checked on the other side of the publish, rebuilt rather than carried across jobs.
- run: pnpm check:dependency-inventory
- name: Attach the inventory to the release this push published
env:
GH_TOKEN: ${{ github.token }}
ASSET: dist-artifacts/dependency-inventory.cdx.json
run: |
set -euo pipefail
version="$(node -p "require('./package.json').version")"
head_sha="$(git rev-parse HEAD)"
# Shape-agnostic: whatever tag carrying this version points at the published commit.
mapfile -t candidates < <(git tag --points-at HEAD | grep -F "$version" || true)
if [ "${#candidates[@]}" -eq 0 ]; then
echo "No tag carrying version $version points at $head_sha."
echo "This push opened or updated a version PR rather than publishing, so there is"
echo "no release to attach an inventory to."
exit 0
fi
tag=""
for candidate in "${candidates[@]}"; do
if gh release view "$candidate" >/dev/null 2>&1; then
tag="$candidate"
break
fi
done
if [ -z "$tag" ]; then
echo "::error::version $version is tagged at $head_sha (${candidates[*]}) but no GitHub release exists for any of those tags, so the inventory cannot be attached."
exit 1
fi
# --clobber is what makes a re-run attach to the SAME release without republishing:
# re-running this job alone uploads over the asset and never touches the registry.
gh release upload "$tag" "$ASSET" --clobber
# A silent skip is the failure mode this guards. The asset is read back from the release
# and its absence fails the run that attempted the attach.
asset_name="$(basename "$ASSET")"
if ! gh release view "$tag" --json assets --jq '.assets[].name' | grep -Fqx "$asset_name"; then
echo "::error::$asset_name is not listed on release $tag after the upload reported success."
exit 1
fi
echo "Attached $asset_name to release $tag."
40 changes: 33 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ this file is maintained by hand (Changesets handles the version bump and publish
## [Unreleased]

**The pending changeset set is classified `minor`, not `patch`, so the next release is a minor
release.** Six changesets are pending and four of them arrived carrying `patch`. Three of those
four were reclassified against their own text: one removes public values and changes what an
release.** Seven changesets are pending and five of them arrived carrying `patch`. Three of those
five were reclassified against their own text: one removes public values and changes what an
exported function returns, and two add public values. None of the three is a fix that adds nothing
and removes nothing, which is the only thing a `patch` may claim, and only those three bump lines
moved. The fourth keeps `patch`: it changes repository tooling and install configuration, touches no
published value and emits no byte differently, so `patch` is what its own text supports. The other
two arrived carrying `minor` already, each written against this same rule, so both are applied as
written rather than reclassified. A set carrying a `patch` beside a `minor` still resolves to the
moved. The other two keep `patch`: one changes repository tooling and install configuration, the
other changes what a release carries beside the package, and neither touches a published value or
emits a byte differently, so `patch` is what each one's own text supports. The last two arrived
carrying `minor` already, each written against this same rule, so both are applied as written rather
than reclassified. A set carrying a `patch` beside a `minor` still resolves to the
minor channel.

The breaking change below therefore ships in the minor channel of the pre-1.0 ladder, which is where
Expand All @@ -30,13 +31,38 @@ mirrors it.

The entries the pending set carries are the date conversion surface entry, the LIVD units entry, the
LIVD publication metadata entry and the vocabulary-attribution entry under Added, and the LIVD
catalog entry and the PHI scanner entry under Changed. This section also holds entries written
catalog entry, the PHI scanner entry and the dependency inventory entry under Changed. This section
also holds entries written
before them, which is where they have always been kept: no released entry was moved, reworded or
deleted by the reclassification, because rewriting a shipped changelog destroys the traceability it
exists for.

### Changed

- **Every published release now carries a machine-readable dependency inventory as a downloadable
asset.** No published value, type, warning code or emitted byte moves: this entry is release
infrastructure only, and nothing under `src/` is touched. The inventory is a CycloneDX document
naming this package and the version being built alongside every dependency that build resolved,
runtime and build-time alike, transitively, each with the exact version it resolved to. It is read
from the lockfile rather than from the manifest's two lists, because the manifest states ranges
while a build resolves versions, so the artifact answers the question a reader of a dependency
claim actually has. A team evaluating this package for a regulated laboratory can download it from
the release page and feed it to their own tooling without cloning anything or reproducing an
install. `scripts/check-dependency-inventory.ts` both produces and grades it, from the checkout,
with no registry credential and no network call, and it is generated into the gitignored
`dist-artifacts/` rather than committed, so it can never describe a version other than the one it
ships beside. The grading runs in `prepublishOnly`, which is BEFORE the registry upload: an
inventory that cannot be produced at all, or that is empty, unparseable, missing this package's
identity or version, or short of a dependency the build resolved, is refused by name and aborts
the publish, so a bad inventory costs a failed run rather than a published version nothing
describes. A refusal also removes any older artifact at that path rather than leaving one to be
read as this build's. Attaching it to the release is a second job in the release workflow, because
the shared pipeline attaches a fixed two-name asset list; that job runs after the publish, since
the release does not exist before one, and it fails its run rather than skipping quietly if the
asset does not arrive. What the inventory does not claim: it states what was resolved and nothing
about whether that is safe. It is not a vulnerability scan, a licence assessment, an attestation
or a signature, it carries component names and versions only, and npm provenance remains the
separate thing it already was.
- **The PHI scanner refuses a run that enumerated a target and never read it, and the install
settings that hold a resolution back are now in force rather than merely written down.** No
published value, type, warning code or emitted byte moves: this entry is repository tooling and
Expand Down
39 changes: 28 additions & 11 deletions documentation/release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ The published version is the `version` field of `package.json`, `0.0.22`, on the
ladder. The pending set below is every file matching `.changeset/*.md` other than `README.md`
(`config.json` is the tool's configuration and carries no bump).

Four of the six pending changesets arrived classified `patch`. Three of those four are reclassified
to `minor` here, each for a reason taken from its own text; their bump lines are the only lines that
were rewritten, and no changeset's prose was edited. The fourth arrived `patch` and stays `patch`,
because its own text removes no public value, adds none, and changes no exported behaviour:
reclassification runs in both directions or it is not a reading, and a tooling change written up as
a feature would overstate the release exactly as a removal written up as a fix understates one. The
other two arrived carrying `minor` already, each written against this same rule, so both are applied
as written rather than reclassified.
Five of the seven pending changesets arrived classified `patch`, and two arrived carrying `minor`.
Three of the five are reclassified to `minor` here, each for a reason taken from its own text; their
bump lines are the only lines that were rewritten, and no changeset's prose was edited. The other
two stay `patch`, because neither one's text removes a public value, adds one, or changes any
exported behaviour: reclassification runs in both directions or it is not a reading, and a tooling
change written up as a feature would overstate the release exactly as a removal written up as a fix
understates one. The two that arrived carrying `minor` were each written against this same rule, so
both are applied as written rather than reclassified.

<!-- audit:begin -->

Expand All @@ -43,15 +43,16 @@ as written rather than reclassified.
| `livd-catalog-answers-the-analyte.md` | `patch` | `minor` |
| `livd-catalog-says-which-loinc-version.md` | `minor` | `minor` |
| `livd-units-choose-between-candidates.md` | `patch` | `minor` |
| `publish-a-dependency-inventory-with-each-release.md` | `patch` | `patch` |
| `say-which-vocabulary-a-letter-was-graded-against.md` | `patch` | `minor` |
| `shared-date-conversion-surface.md` | `minor` | `minor` |

<!-- audit:end -->

Highest bump in the set: `minor`. Applied to the published `0.0.22`, Changesets resolves
`@cosyte/astm` to **`0.1.0`**. The `patch` above does not move that: a `patch` beside a `minor`
changes nothing about the resolved version, and it is recorded rather than dropped because a
pending changeset absent from this table is what section 1 exists to make impossible.
`@cosyte/astm` to **`0.1.0`**. Neither `patch` above moves that: a `patch` beside a `minor` changes
nothing about the resolved version, and both are recorded rather than dropped because a pending
changeset absent from this table is what section 1 exists to make impossible.

### `livd-catalog-answers-the-analyte.md`: `minor`

Expand Down Expand Up @@ -153,6 +154,22 @@ That is what a `patch` claims, so `patch` is what it keeps. The reclassification
section is a reading of each changeset's own text, and a reading that only ever moved a bump upward
would not be one.

### `publish-a-dependency-inventory-with-each-release.md`: `patch`

Its own text adds `scripts/check-dependency-inventory.ts`, a package script that runs it, a second
job in `.github/workflows/release.yml`, and one clause on the end of `prepublishOnly`. What a
consumer gains is an asset on the release page, which is release infrastructure rather than package
surface: the entry point re-exports nothing from `scripts/`, no `src/` file is touched, and the
inventory is generated into the gitignored `dist-artifacts/` rather than committed or shipped in the
tarball. Measured against section 3's enumeration, it adds no value, removes none, renames none, and
changes no exported function's behaviour. It emits no byte differently: a parsed and re-serialized
record is unchanged, and no warning code moves.

The one thing it does change for a consumer is what a published release carries beside the package,
and that is the whole reason it takes a changeset at all rather than riding a release silently. A
change to what ships with a release is still a `patch` when the published values are untouched, so
`patch` is what it keeps.

### `shared-date-conversion-surface.md`: `minor`

Its own text is headed "Added", and everything under that heading is public: the exported functions
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@
"changeset": "changeset",
"typecheck": "tsc --noEmit",
"phi-scan": "tsx scripts/phi-scan.ts",
"check": "pnpm run check:gate-coverage && pnpm run check:agent-notes",
"check": "pnpm run check:gate-coverage && pnpm run check:agent-notes && pnpm run check:dependency-inventory",
"check:agent-notes": "tsx scripts/check-agent-notes.ts",
"check:dependency-inventory": "tsx scripts/check-dependency-inventory.ts",
"check:gate-coverage": "tsx scripts/check-gate-coverage.ts",
"check:no-emdash": "bash scripts/check-no-emdash.sh",
"check:no-internal-refs": "bash scripts/check-no-internal-refs.sh",
Expand All @@ -83,7 +84,7 @@
"smoke": "node scripts/smoke.mjs",
"version": "changeset version && node scripts/sync-version.mjs && prettier --write package.json CHANGELOG.md src/index.ts",
"release": "changeset publish",
"prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm attw",
"prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm attw && pnpm check:dependency-inventory",
"prepare": "command -v simple-git-hooks >/dev/null 2>&1 && simple-git-hooks || true"
},
"simple-git-hooks": {
Expand Down
Loading
Loading