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
26 changes: 26 additions & 0 deletions .github/scripts/ci_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,31 @@ def classify(
or path == ".github/workflows/docs-pages.yml"
for path in paths
)
docs_archives = any(
Comment thread
jeremi marked this conversation as resolved.
Comment thread
jeremi marked this conversation as resolved.
path
in {
".github/scripts/ci_changes.py",
".github/workflows/ci.yml",
".github/workflows/docs-pages.yml",
".github/workflows/release.yml",
"docs/site/astro.config.mjs",
"docs/site/package-lock.json",
"docs/site/package.json",
"docs/site/scripts/apply-archive-seo.mjs",
"docs/site/scripts/archive-bundle.mjs",
"docs/site/scripts/archive-lock.mjs",
"docs/site/scripts/assemble-archives.mjs",
"docs/site/scripts/build-archive.mjs",
"docs/site/scripts/build-archives.mjs",
"docs/site/scripts/check-built-links.mjs",
"docs/site/scripts/check-seo.mjs",
"docs/site/scripts/docsets.mjs",
"docs/site/src/data/archive-lock.yaml",
"docs/site/src/data/docsets.yaml",
"docs/site/src/data/repo-docs.yaml",
}
for path in paths
)
editors = complete or any(path.startswith("editors/") for path in paths)

tutorial_infrastructure = any(
Expand Down Expand Up @@ -313,6 +338,7 @@ def classify(
"release_tool": release_tool,
"release_source_proof": release_source_proof,
"docs": docs,
"docs_archives": docs_archives,
"editors": editors,
"registryctl_tutorial": registryctl_tutorial,
}
Expand Down
44 changes: 44 additions & 0 deletions .github/scripts/test_ci_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def test_ci_workflow_change_runs_the_complete_matrix(self) -> None:
outputs = classify(self.workspace, (".github/workflows/ci.yml",))
self.assertCountEqual(outputs["rust_packages"], self.workspace.package_names)
self.assertTrue(outputs["docs"])
self.assertTrue(outputs["docs_archives"])
self.assertTrue(outputs["editors"])

def test_docs_only_change_skips_rust(self) -> None:
Expand All @@ -70,6 +71,49 @@ def test_docs_only_change_skips_rust(self) -> None:
self.assertFalse(outputs["rust"])
self.assertEqual(outputs["rust_matrix"], {"include": []})
self.assertTrue(outputs["docs"])
self.assertFalse(outputs["docs_archives"])

def test_archive_content_is_immutable_during_routine_docs_changes(self) -> None:
current_content = classify(
self.workspace,
("docs/site/src/content/docs/reference/glossary.mdx",),
)
archive_lock = classify(
self.workspace,
("docs/site/src/data/archive-lock.yaml",),
)
archive_assembler = classify(
self.workspace,
("docs/site/scripts/assemble-archives.mjs",),
)
self.assertFalse(current_content["docs_archives"])
self.assertTrue(archive_lock["docs_archives"])
self.assertTrue(archive_assembler["docs_archives"])

def test_archive_dependent_scripts_select_archive_verification(self) -> None:
for path in (
".github/scripts/ci_changes.py",
"docs/site/scripts/check-built-links.mjs",
"docs/site/scripts/check-seo.mjs",
"docs/site/scripts/docsets.mjs",
):
with self.subTest(path=path):
outputs = classify(self.workspace, (path,))
self.assertTrue(outputs["docs_archives"])

def test_run_all_does_not_rebuild_immutable_archives_without_changed_paths(self) -> None:
outputs = classify(self.workspace, (), run_all=True)
self.assertTrue(outputs["docs"])
self.assertFalse(outputs["docs_archives"])

def test_run_all_keeps_archive_sensitive_changed_paths(self) -> None:
outputs = classify(
self.workspace,
("docs/site/scripts/archive-bundle.mjs",),
run_all=True,
)
self.assertTrue(outputs["docs"])
self.assertTrue(outputs["docs_archives"])

def test_other_workflow_changes_do_not_select_the_full_matrix(self) -> None:
for workflow in sorted(RELEASE_SECURITY_WORKFLOWS):
Expand Down
58 changes: 55 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
release_tool: ${{ steps.filter.outputs.release_tool }}
release_source_proof: ${{ steps.filter.outputs.release_source_proof }}
docs: ${{ steps.filter.outputs.docs }}
docs_archives: ${{ steps.filter.outputs.docs_archives }}
editors: ${{ steps.filter.outputs.editors }}
registryctl_tutorial: ${{ steps.filter.outputs.registryctl_tutorial }}
steps:
Expand All @@ -61,8 +62,8 @@ jobs:
- name: Classify changed paths
id: filter
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }}
shell: bash
run: |
set -euo pipefail
Expand All @@ -82,8 +83,15 @@ jobs:
else
classifier+=(--all)
fi
elif git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null &&
git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
# Keep push and merge-queue complete matrices while preserving
# paths for gates that intentionally do not run on every change.
git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" > "${changed_files}"
classifier+=(--all --changed-files "${changed_files}")
else
# Pushes and merge-queue candidates run the complete matrix.
# Runs without resolvable comparison commits use the complete
# matrix and leave opt-in immutable archive verification skipped.
classifier+=(--all)
fi
"${classifier[@]}"
Expand Down Expand Up @@ -903,6 +911,7 @@ jobs:
needs:
- changes
- docs
- docs-archives
if: ${{ always() }}
runs-on: ubuntu-24.04
steps:
Expand All @@ -911,6 +920,8 @@ jobs:
CHANGES_RESULT: ${{ needs.changes.result }}
REQUIRED: ${{ needs.changes.outputs.docs }}
RESULT: ${{ needs.docs.result }}
ARCHIVES_REQUIRED: ${{ needs.changes.outputs.docs_archives }}
ARCHIVES_RESULT: ${{ needs.docs-archives.result }}
shell: bash
run: |
set -euo pipefail
Expand All @@ -922,6 +933,47 @@ jobs:
exit 1
;;
esac
case "${CHANGES_RESULT}:${ARCHIVES_REQUIRED}:${ARCHIVES_RESULT}" in
success:true:success|success:false:skipped)
;;
*)
echo "Docs archive path gate did not complete safely: changes=${CHANGES_RESULT} required=${ARCHIVES_REQUIRED} result=${ARCHIVES_RESULT}" >&2
exit 1
;;
esac

docs-archives:
name: Immutable docs archives
needs: changes
if: needs.changes.outputs.docs_archives == 'true'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
fetch-depth: 0
submodules: false

- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 22.12.0
cache: npm
cache-dependency-path: docs/site/package-lock.json

- name: Install docs dependencies
working-directory: docs/site
run: npm ci

- name: Reject changes to locked archive digests
working-directory: docs/site
env:
ARCHIVE_LOCK_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'origin/main' }}
run: npm run check:archive-lock -- --base-ref "${ARCHIVE_LOCK_BASE_REF}"

- name: Assemble and verify every archived release
working-directory: docs/site
run: npm run check:archives

editor-extensions:
name: Editor extensions
Expand Down
18 changes: 12 additions & 6 deletions .github/workflows/docs-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,19 @@ jobs:
PUBLIC_UMAMI_SCRIPT_SRC: ${{ vars.PUBLIC_UMAMI_SCRIPT_SRC }}
PUBLIC_UMAMI_DOMAINS: ${{ vars.PUBLIC_UMAMI_DOMAINS }}

- name: Build archived docs
- name: Verify current machine-readable documentation
working-directory: docs/site
run: npm run build:archives
env:
PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.PUBLIC_UMAMI_WEBSITE_ID }}
PUBLIC_UMAMI_SCRIPT_SRC: ${{ vars.PUBLIC_UMAMI_SCRIPT_SRC }}
PUBLIC_UMAMI_DOMAINS: ${{ vars.PUBLIC_UMAMI_DOMAINS }}
run: npm run check:llms:built

- name: Assemble immutable archived docs
working-directory: docs/site
run: npm run assemble:archives -- --bootstrap

- name: Verify assembled documentation tree
working-directory: docs/site
run: |
npm run check:seo:built
npm run check:links:built

- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6
Expand Down
80 changes: 79 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ jobs:
"${{ steps.release.outputs.tag_target }}" refs/remotes/origin/main
release/scripts/registry-release validate \
"${{ steps.release.outputs.manifest }}"
release/scripts/registry-release validate-docsets
release/scripts/registry-release validate-source \
"${{ steps.release.outputs.manifest }}" \
--tag "${{ steps.release.outputs.tag }}" \
Expand All @@ -150,6 +151,53 @@ jobs:
REGISTRY_RELEASE_SOURCE_MODE=monorepo \
release/scripts/check-release-source-model.sh

docs-archive:
name: Build immutable release docs archive
needs: verify
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout exact tag target without persisted credentials
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4.2.2
with:
ref: ${{ needs.verify.outputs.tag_target }}
fetch-depth: 0
submodules: false
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4.4.0
with:
node-version: 22.12.0
cache: npm
cache-dependency-path: docs/site/package-lock.json

- name: Install locked docs dependencies
working-directory: docs/site
run: npm ci

- name: Build and verify the tag-bound docs bundle
working-directory: docs/site
env:
DOCS_DOCSET: ${{ needs.verify.outputs.tag }}
DOCS_ARCHIVE_OUTPUT: ${{ runner.temp }}/registry-docs-${{ needs.verify.outputs.tag }}.tar.gz
run: |
set -euo pipefail
npm run build:archive
npm run archive:snapshot -- \
"${DOCS_DOCSET}" \
--output "${DOCS_ARCHIVE_OUTPUT}" \
--verify-lock

- name: Upload verified docs bundle
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: registry-docs-${{ needs.verify.outputs.tag }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/registry-docs-${{ needs.verify.outputs.tag }}.tar.gz
if-no-files-found: error
retention-days: 7

verify-candidate:
name: Verify exact candidate before any public write
needs: verify
Expand Down Expand Up @@ -464,6 +512,7 @@ jobs:
needs:
- verify
- verify-candidate
- docs-archive
runs-on: ubuntu-24.04
permissions:
actions: read
Expand Down Expand Up @@ -603,6 +652,7 @@ jobs:
- verify
- verify-candidate
- publish-images
- docs-archive
runs-on: ubuntu-24.04
outputs:
provenance_subjects: ${{ steps.provenance-subjects.outputs.base64_subjects }}
Expand All @@ -627,6 +677,12 @@ jobs:
name: ${{ needs.verify-candidate.outputs.bundle_name }}
path: promotion

- name: Download unprivileged docs archive
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: registry-docs-${{ needs.verify.outputs.tag }}-${{ github.run_id }}-${{ github.run_attempt }}
path: docs-archive

- name: Reverify candidate file inventory without executing product binaries
env:
EXPECTED_RECEIPT_SHA256: ${{ needs.verify.outputs.receipt_sha256 }}
Expand Down Expand Up @@ -743,6 +799,27 @@ jobs:
payload="promotion/artifacts/${payload_name}/dist"
mkdir -p dist/{bin,image-evidence,binary-sbom,release-capsule}
cp -R "${payload}/bin/." dist/bin/
docs_archive="docs-archive/registry-docs-${{ needs.verify.outputs.tag }}.tar.gz"
test -f "${docs_archive}"
python3 - \
"docs/site/src/data/archive-lock.yaml" \
"${{ needs.verify.outputs.tag }}" \
"${docs_archive}" <<'PY'
import hashlib
import pathlib
import sys
import yaml

lock_path, docset_id, bundle_path = sys.argv[1:]
lock = yaml.safe_load(pathlib.Path(lock_path).read_text(encoding="utf-8"))
expected = lock["archives"][docset_id]["bundle_sha256"]
actual = hashlib.sha256(pathlib.Path(bundle_path).read_bytes()).hexdigest()
if actual != expected:
raise SystemExit(
f"docs archive digest {actual} does not match immutable lock {expected}"
)
PY
cp "${docs_archive}" dist/bin/
cp -R "${payload}/images/." dist/image-evidence/
cp -R "${payload}/sbom/." dist/image-evidence/
cp -R "${payload}/grype/." dist/image-evidence/
Expand Down Expand Up @@ -801,7 +878,8 @@ jobs:
--default-branch-protection unverified \
--repo . \
--default-branch origin/main \
--require-registryctl-image-lock
--require-registryctl-image-lock \
--require-registry-docs-archive

- name: Install cosign
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.10.1
Expand Down
1 change: 1 addition & 0 deletions docs/site/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ npm-debug.log*
src/content/docs/products/
public/products/
.repo-docs-cache/
.archive-bundles/

# OpenAPI specs fetched at the pinned ref by scripts/fetch-openapi.mjs
# (build artifacts; redocly reads them but they are regenerated each build).
Expand Down
30 changes: 29 additions & 1 deletion docs/site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,35 @@ npm run check

The check command validates frontmatter, generated data, Markdown structure,
prose style, OpenAPI snapshots, SVG accessibility, Astro types, the static
build, and generated Redoc API pages.
build, and generated Redoc API pages. It checks the current site only. Published
release archives are immutable bundles and are not rebuilt during routine docs
work.

To verify the complete deployable tree, including every locked release archive:

```sh
npm run check:archives
```

`src/data/archive-lock.yaml` is append-only. Each entry binds one archived
docset to the SHA-256 of its deterministic bundle and extracted site tree.
Existing entries must never be edited or removed. `npm run
check:archive-lock -- --base-ref origin/main` enforces that invariant.
Archived builds omit Pagefind because its generated index differs by host
platform; current documentation keeps search enabled.

To publish a new archived docset, build only that docset and create its bundle:

```sh
DOCS_DOCSET=vX.Y.Z npm run build:archive
npm run archive:snapshot -- vX.Y.Z --write-lock
npm run generate
npm run check:archive-lock -- --base-ref origin/main
```

The release workflow repeats those steps from the exact annotated tag and
publishes `registry-docs-vX.Y.Z.tar.gz` with the other signed, SBOM-covered,
SLSA-provenanced release files.

## Content Sources

Expand Down
Loading
Loading