fix(ci): harden publishing and validate examples

This commit is contained in:
yeasy
2026-07-10 19:29:30 -07:00
parent 8eabe30dc2
commit daa6661b2b
26 changed files with 27456 additions and 171 deletions
+102 -57
View File
@@ -6,46 +6,71 @@ on:
- 'v*' - 'v*'
workflow_dispatch: workflow_dispatch:
permissions: permissions: {}
contents: write
jobs: jobs:
release: build:
permissions:
contents: read
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check project rules - name: Install locked Node dependencies
run: python3 check_project_rules.py env:
PUPPETEER_SKIP_DOWNLOAD: "true"
run: npm ci
- name: Check release metadata - name: Check project rules and metadata
run: npm test run: |
python3 check_project_rules.py
npm test
- name: Install Chromium and CJK fonts - name: Install Chromium, CJK fonts, and PDF inspection tools
id: setupchrome id: setupchrome
uses: browser-actions/setup-chrome@v2 uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2
with: with:
chrome-version: stable chrome-version: stable
- name: Install CJK fonts
- name: Install CJK fonts and PDF inspection tools
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y fonts-noto-cjk fonts-noto-cjk-extra sudo apt-get install -y fonts-noto-cjk fonts-noto-cjk-extra poppler-utils
- name: Install mdpress 0.7.10 - name: Install mdPress 0.7.10
env: env:
MDPRESS_VERSION: "0.7.10" MDPRESS_VERSION: "0.7.10"
MDPRESS_SHA256: "17e53e455996940bbbce64c69c43b3fb543f1501e03b74cf0434074efebd2db4" MDPRESS_SHA256: "17e53e455996940bbbce64c69c43b3fb543f1501e03b74cf0434074efebd2db4"
run: | run: |
archive="/tmp/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz" archive="$RUNNER_TEMP/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz"
echo "Installing mdpress ${MDPRESS_VERSION}" curl -fsSL --retry 3 \
curl -fsSL "https://github.com/yeasy/mdPress/releases/download/v${MDPRESS_VERSION}/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz" -o "$archive" "https://github.com/yeasy/mdPress/releases/download/v${MDPRESS_VERSION}/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz" \
-o "$archive"
echo "${MDPRESS_SHA256} $archive" | sha256sum -c - echo "${MDPRESS_SHA256} $archive" | sha256sum -c -
tar xzf "$archive" -C /tmp mdpress tar xzf "$archive" -C "$RUNNER_TEMP" mdpress
sudo mv /tmp/mdpress /usr/local/bin/ mkdir -p "$RUNNER_TEMP/bin"
mdpress --version install -m 0755 "$RUNNER_TEMP/mdpress" "$RUNNER_TEMP/bin/mdpress"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Extract tag name - name: Install Pandoc 3.5
env:
PANDOC_VERSION: "3.5"
PANDOC_SHA256: "4f41d817d262ef3d17953a3e6e0fefddb971aa4f2f121544a9a86db449d945e1"
run: |
package="$RUNNER_TEMP/pandoc-${PANDOC_VERSION}-1-amd64.deb"
curl -fsSL --retry 3 \
"https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-1-amd64.deb" \
-o "$package"
echo "${PANDOC_SHA256} $package" | sha256sum -c -
sudo dpkg -i "$package"
pandoc --version | head -1
- name: Extract safe tag name
id: tag id: tag
shell: bash
run: | run: |
if [[ "$GITHUB_REF" == refs/tags/* ]]; then if [[ "$GITHUB_REF" == refs/tags/* ]]; then
tag_name="${GITHUB_REF#refs/tags/}" tag_name="${GITHUB_REF#refs/tags/}"
@@ -61,49 +86,69 @@ jobs:
run: python3 tools/prepare_pdf_sources.py --book-dir . --out "$RUNNER_TEMP/docker_practice-pdf-src" run: python3 tools/prepare_pdf_sources.py --book-dir . --out "$RUNNER_TEMP/docker_practice-pdf-src"
- name: Build PDF - name: Build PDF
env:
SAFE_TAG_NAME: ${{ steps.tag.outputs.SAFE_TAG_NAME }}
working-directory: ${{ runner.temp }}/docker_practice-pdf-src working-directory: ${{ runner.temp }}/docker_practice-pdf-src
run: mdpress build --format pdf --output "${GITHUB_WORKSPACE}/docker_practice-${{ steps.tag.outputs.SAFE_TAG_NAME || 'latest' }}.pdf" run: |
mkdir -p "$GITHUB_WORKSPACE/dist"
- name: Create Release with PDF mdpress build --format pdf \
if: startsWith(github.ref, 'refs/tags/') --output "$GITHUB_WORKSPACE/dist/docker_practice-${SAFE_TAG_NAME}.pdf"
uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
files: docker_practice-${{ steps.tag.outputs.SAFE_TAG_NAME }}.pdf
- name: Upload PDF as artifact
uses: actions/upload-artifact@v7
with:
name: docker_practice-pdf
path: "docker_practice-*.pdf"
- name: Build HTML reader - name: Build HTML reader
id: htmlreader
continue-on-error: true
env: env:
CHROME_BIN: ${{ steps.setupchrome.outputs.chrome-path }} CHROME_BIN: ${{ steps.setupchrome.outputs.chrome-path }}
SAFE_TAG_NAME: ${{ steps.tag.outputs.SAFE_TAG_NAME }}
run: | run: |
# recent pandoc (apt's is too old for --embed-resources); + mermaid-cli using system Chrome title=$(python3 -c 'import json; print(json.load(open("book.json", encoding="utf-8"))["title"])')
curl -fsSL https://github.com/jgm/pandoc/releases/download/3.5/pandoc-3.5-1-amd64.deb -o /tmp/pandoc.deb PATH="$GITHUB_WORKSPACE/node_modules/.bin:$PATH" \
sudo dpkg -i /tmp/pandoc.deb && pandoc --version | head -1 python3 tools/render_mermaid.py --book-dir . --svg-out "$RUNNER_TEMP/mmsvg"
PUPPETEER_SKIP_DOWNLOAD=true npm install -g @mermaid-js/mermaid-cli@10 python3 tools/build_html_reader.py \
TITLE=$(python3 -c "import json,os;print((json.load(open('book.json')).get('title') if os.path.exists('book.json') else '') or '${{ github.event.repository.name }}')") --book-dir . \
TAG="${{ steps.tag.outputs.SAFE_TAG_NAME || 'latest' }}" --title "$title" \
python3 tools/render_mermaid.py --book-dir . --svg-out /tmp/mmsvg --svg-dir "$RUNNER_TEMP/mmsvg" \
python3 tools/build_html_reader.py --book-dir . --title "$TITLE" --svg-dir /tmp/mmsvg \ --out "dist/docker_practice-${SAFE_TAG_NAME}.html"
--out "${{ github.event.repository.name }}-${TAG}.html"
ls -lh ${{ github.event.repository.name }}-${TAG}.html
- name: Attach HTML to release - name: Verify release artifacts
if: steps.htmlreader.outcome == 'success' && startsWith(github.ref, 'refs/tags/') env:
uses: softprops/action-gh-release@v3 SAFE_TAG_NAME: ${{ steps.tag.outputs.SAFE_TAG_NAME }}
with: run: |
tag_name: ${{ steps.tag.outputs.TAG_NAME }} title=$(python3 -c 'import json; print(json.load(open("book.json", encoding="utf-8"))["title"])')
files: "${{ github.event.repository.name }}-${{ steps.tag.outputs.SAFE_TAG_NAME }}.html" python3 tools/verify_artifacts.py \
--title "$title" \
--pdf "dist/docker_practice-${SAFE_TAG_NAME}.pdf" \
--html "dist/docker_practice-${SAFE_TAG_NAME}.html" \
--checksums dist/SHA256SUMS
(cd dist && sha256sum -c SHA256SUMS)
- name: Upload HTML as artifact - name: Upload verified release bundle
if: steps.htmlreader.outcome == 'success' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@v7
with: with:
name: html-edition name: docker-practice-release-bundle
path: "${{ github.event.repository.name }}-*.html" path: dist/
if-no-files-found: error
release:
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
needs: build
runs-on: ubuntu-latest
steps:
- name: Download verified release bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docker-practice-release-bundle
path: dist
- name: Recheck artifact checksums
run: (cd dist && sha256sum -c SHA256SUMS)
- name: Create GitHub release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
generate_release_notes: true
fail_on_unmatched_files: true
files: |
dist/docker_practice-*.pdf
dist/docker_practice-*.html
dist/SHA256SUMS
+14 -8
View File
@@ -3,17 +3,24 @@ name: Check link
on: on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
check-link: check-link:
name: check-link
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - name: Checkout
# search Issues :-( uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- run: | with:
persist-credentials: false
- name: Check Markdown links
run: |
docker run -i --rm \ docker run -i --rm \
-v $PWD:/mnt:ro \ -v "$GITHUB_WORKSPACE:/mnt:ro" \
dkhamsing/awesome_bot \ -w /mnt \
dkhamsing/awesome_bot@sha256:a8adaeb3b3bd5745304743e4d8a6d512127646e420544a6d22d9f58a07f35884 \
--white-list "localhost","0.0.0.0",\ --white-list "localhost","0.0.0.0",\
"server","example.com","docker",\ "server","example.com","docker",\
"docker.domain.com","YourIP","register",\ "docker.domain.com","YourIP","register",\
@@ -60,6 +67,5 @@ jobs:
--allow-dupe \ --allow-dupe \
--skip-save-results \ --skip-save-results \
-t 10 \ -t 10 \
`find . \( -path "./mesos" -o -path "./swarm_mode" \) -prune -o -name "*.md" -exec ls {} \;` $(find . \( -path "./mesos" -o -path "./swarm_mode" \) -prune -o -name "*.md" -print)
name: check-link
timeout-minutes: 25 timeout-minutes: 25
+80 -23
View File
@@ -7,48 +7,105 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
permissions: permissions: {}
contents: read
jobs: jobs:
build: build:
name: Build permissions:
contents: read
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - name: Checkout
- name: Check project rules uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
run: python3 check_project_rules.py with:
- name: Check release metadata persist-credentials: false
run: npm test
- name: Install Chromium and CJK fonts - name: Install locked Node dependencies
uses: browser-actions/setup-chrome@v2 env:
PUPPETEER_SKIP_DOWNLOAD: "true"
run: npm ci
- name: Install canonical-example validators
env:
KUBECONFORM_VERSION: "0.8.0"
KUBECONFORM_SHA256: "9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883"
ACTIONLINT_VERSION: "1.7.12"
ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8"
run: |
mkdir -p "$RUNNER_TEMP/bin"
kube_archive="$RUNNER_TEMP/kubeconform.tar.gz"
curl -fsSL --retry 3 \
"https://github.com/yannh/kubeconform/releases/download/v${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" \
-o "$kube_archive"
echo "${KUBECONFORM_SHA256} $kube_archive" | sha256sum -c -
tar xzf "$kube_archive" -C "$RUNNER_TEMP/bin" kubeconform
actionlint_archive="$RUNNER_TEMP/actionlint.tar.gz"
curl -fsSL --retry 3 \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
-o "$actionlint_archive"
echo "${ACTIONLINT_SHA256} $actionlint_archive" | sha256sum -c -
tar xzf "$actionlint_archive" -C "$RUNNER_TEMP/bin" actionlint
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Check project rules, metadata, and canonical examples
run: |
python3 check_project_rules.py
npm test
python3 tools/test_examples.py --require-tools
- name: Install Chromium
uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2
with: with:
chrome-version: stable chrome-version: stable
- name: Install CJK fonts
- name: Install CJK fonts and PDF inspection tools
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y fonts-noto-cjk fonts-noto-cjk-extra sudo apt-get install -y fonts-noto-cjk fonts-noto-cjk-extra poppler-utils
- name: Install mdpress 0.7.10
- name: Install mdPress 0.7.10
env: env:
MDPRESS_VERSION: "0.7.10" MDPRESS_VERSION: "0.7.10"
MDPRESS_SHA256: "17e53e455996940bbbce64c69c43b3fb543f1501e03b74cf0434074efebd2db4" MDPRESS_SHA256: "17e53e455996940bbbce64c69c43b3fb543f1501e03b74cf0434074efebd2db4"
run: | run: |
archive="/tmp/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz" archive="$RUNNER_TEMP/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz"
echo "Installing mdpress ${MDPRESS_VERSION}" curl -fsSL --retry 3 \
curl -fsSL "https://github.com/yeasy/mdPress/releases/download/v${MDPRESS_VERSION}/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz" -o "$archive" "https://github.com/yeasy/mdPress/releases/download/v${MDPRESS_VERSION}/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz" \
-o "$archive"
echo "${MDPRESS_SHA256} $archive" | sha256sum -c - echo "${MDPRESS_SHA256} $archive" | sha256sum -c -
tar xzf "$archive" tar xzf "$archive" -C "$RUNNER_TEMP" mdpress
sudo mv mdpress /usr/local/bin/ mkdir -p "$RUNNER_TEMP/bin"
mdpress --version install -m 0755 "$RUNNER_TEMP/mdpress" "$RUNNER_TEMP/bin/mdpress"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Prepare PDF sources - name: Prepare PDF sources
run: python3 tools/prepare_pdf_sources.py --book-dir . --out "$RUNNER_TEMP/docker_practice-pdf-src" run: python3 tools/prepare_pdf_sources.py --book-dir . --out "$RUNNER_TEMP/docker_practice-pdf-src"
- name: Build PDF - name: Build PDF
working-directory: ${{ runner.temp }}/docker_practice-pdf-src working-directory: ${{ runner.temp }}/docker_practice-pdf-src
run: mdpress build --format pdf --output "$GITHUB_WORKSPACE/docker_practice.pdf" run: |
mkdir -p "$GITHUB_WORKSPACE/dist"
mdpress build --format pdf --output "$GITHUB_WORKSPACE/dist/docker_practice.pdf"
- name: Build site - name: Build site
run: npm run build run: npm run build
- name: Upload PDF as artifact
uses: actions/upload-artifact@v7 - name: Verify build artifacts
run: |
title=$(python3 -c 'import json; print(json.load(open("book.json", encoding="utf-8"))["title"])')
python3 tools/verify_artifacts.py \
--title "$title" \
--pdf dist/docker_practice.pdf \
--site _site \
--checksums dist/SHA256SUMS
(cd dist && sha256sum -c SHA256SUMS)
- name: Upload verified PDF
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: docker_practice-pdf name: docker_practice-pdf
path: docker_practice.pdf path: |
dist/docker_practice.pdf
dist/SHA256SUMS
if-no-files-found: error
+6 -5
View File
@@ -1,19 +1,20 @@
name: Dependabot auto-merge name: Dependabot auto-merge
on: pull_request on: pull_request
permissions: permissions: {}
contents: write
pull-requests: write
checks: read
jobs: jobs:
dependabot: dependabot:
permissions:
contents: write
pull-requests: write
checks: read
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]' if: github.actor == 'dependabot[bot]'
steps: steps:
- name: Dependabot metadata - name: Dependabot metadata
id: metadata id: metadata
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3 uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
with: with:
github-token: "${{ secrets.GITHUB_TOKEN }}" github-token: "${{ secrets.GITHUB_TOKEN }}"
+79
View File
@@ -0,0 +1,79 @@
name: Pages
# One-time repository setup remains manual: Settings > Pages > Source -> GitHub Actions.
on:
push:
branches:
- master
workflow_dispatch:
permissions: {}
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
permissions:
contents: read
pages: read
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install locked Node dependencies
env:
PUPPETEER_SKIP_DOWNLOAD: "true"
run: npm ci
- name: Check project rules and metadata
run: |
python3 check_project_rules.py
npm test
- name: Install mdPress 0.7.10
env:
MDPRESS_VERSION: "0.7.10"
MDPRESS_SHA256: "17e53e455996940bbbce64c69c43b3fb543f1501e03b74cf0434074efebd2db4"
run: |
archive="$RUNNER_TEMP/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz"
curl -fsSL --retry 3 \
"https://github.com/yeasy/mdPress/releases/download/v${MDPRESS_VERSION}/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz" \
-o "$archive"
echo "${MDPRESS_SHA256} $archive" | sha256sum -c -
tar xzf "$archive" -C "$RUNNER_TEMP" mdpress
mkdir -p "$RUNNER_TEMP/bin"
install -m 0755 "$RUNNER_TEMP/mdpress" "$RUNNER_TEMP/bin/mdpress"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Build mdPress site
run: npm run build
- name: Verify site output
run: test -s _site/index.html
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: _site
deploy:
needs: build
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+71 -28
View File
@@ -6,49 +6,57 @@ on:
- master - master
workflow_dispatch: workflow_dispatch:
permissions: permissions: {}
contents: write
concurrency: concurrency:
group: preview-pdf group: preview-pdf
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
update-preview-pdf: build:
name: Update preview release permissions:
contents: read
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps: steps:
- uses: actions/checkout@v7 - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check project rules - name: Install locked Node dependencies
run: python3 check_project_rules.py env:
PUPPETEER_SKIP_DOWNLOAD: "true"
run: npm ci
- name: Check release metadata - name: Check project rules and metadata
run: npm test run: |
python3 check_project_rules.py
npm test
- name: Install Chromium and CJK fonts - name: Install Chromium
uses: browser-actions/setup-chrome@v2 uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2
with: with:
chrome-version: stable chrome-version: stable
- name: Install CJK fonts
- name: Install CJK fonts and PDF inspection tools
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y fonts-noto-cjk fonts-noto-cjk-extra sudo apt-get install -y fonts-noto-cjk fonts-noto-cjk-extra poppler-utils
- name: Install mdpress 0.7.10 - name: Install mdPress 0.7.10
env: env:
MDPRESS_VERSION: "0.7.10" MDPRESS_VERSION: "0.7.10"
MDPRESS_SHA256: "17e53e455996940bbbce64c69c43b3fb543f1501e03b74cf0434074efebd2db4" MDPRESS_SHA256: "17e53e455996940bbbce64c69c43b3fb543f1501e03b74cf0434074efebd2db4"
run: | run: |
archive="/tmp/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz" archive="$RUNNER_TEMP/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz"
echo "Installing mdpress ${MDPRESS_VERSION}" curl -fsSL --retry 3 \
curl -fsSL "https://github.com/yeasy/mdPress/releases/download/v${MDPRESS_VERSION}/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz" -o "$archive" "https://github.com/yeasy/mdPress/releases/download/v${MDPRESS_VERSION}/mdpress_${MDPRESS_VERSION}_linux_amd64.tar.gz" \
-o "$archive"
echo "${MDPRESS_SHA256} $archive" | sha256sum -c - echo "${MDPRESS_SHA256} $archive" | sha256sum -c -
tar xzf "$archive" -C /tmp mdpress tar xzf "$archive" -C "$RUNNER_TEMP" mdpress
sudo mv /tmp/mdpress /usr/local/bin/ mkdir -p "$RUNNER_TEMP/bin"
mdpress --version install -m 0755 "$RUNNER_TEMP/mdpress" "$RUNNER_TEMP/bin/mdpress"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Prepare PDF sources - name: Prepare PDF sources
run: python3 tools/prepare_pdf_sources.py --book-dir . --out "$RUNNER_TEMP/docker_practice-pdf-src" run: python3 tools/prepare_pdf_sources.py --book-dir . --out "$RUNNER_TEMP/docker_practice-pdf-src"
@@ -59,6 +67,39 @@ jobs:
mkdir -p "$GITHUB_WORKSPACE/dist" mkdir -p "$GITHUB_WORKSPACE/dist"
mdpress build --format pdf --output "$GITHUB_WORKSPACE/dist/docker_practice.pdf" mdpress build --format pdf --output "$GITHUB_WORKSPACE/dist/docker_practice.pdf"
- name: Verify preview PDF
run: |
title=$(python3 -c 'import json; print(json.load(open("book.json", encoding="utf-8"))["title"])')
python3 tools/verify_artifacts.py \
--title "$title" \
--pdf dist/docker_practice.pdf \
--checksums dist/SHA256SUMS
(cd dist && sha256sum -c SHA256SUMS)
- name: Upload verified preview bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docker-practice-preview
path: dist/
if-no-files-found: error
publish:
permissions:
contents: write
needs: build
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Download verified preview bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docker-practice-preview
path: dist
- name: Recheck preview checksum
run: (cd dist && sha256sum -c SHA256SUMS)
- name: Write release notes - name: Write release notes
run: | run: |
cat > dist/release-notes.md <<EOF cat > dist/release-notes.md <<EOF
@@ -69,7 +110,7 @@ jobs:
- Run: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} - Run: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}
EOF EOF
- name: Create preview release - name: Create preview release when absent
run: | run: |
gh release view preview-pdf >/dev/null 2>&1 || \ gh release view preview-pdf >/dev/null 2>&1 || \
gh release create preview-pdf \ gh release create preview-pdf \
@@ -87,8 +128,10 @@ jobs:
--prerelease \ --prerelease \
--target "$GITHUB_SHA" --target "$GITHUB_SHA"
- name: Remove legacy preview asset - name: Replace preview assets
run: gh release delete-asset preview-pdf latest.pdf -y || true run: |
gh release delete-asset preview-pdf latest.pdf -y || true
- name: Upload preview PDF gh release upload preview-pdf \
run: gh release upload preview-pdf dist/docker_practice.pdf --clobber dist/docker_practice.pdf \
dist/SHA256SUMS \
--clobber
-1
View File
@@ -10,7 +10,6 @@ format_report.txt
.DS_Store .DS_Store
node_modules/ node_modules/
package-lock.json
docker-compose.override.yml docker-compose.override.yml
06_repository/demo/auth/nginx.htpasswd 06_repository/demo/auth/nginx.htpasswd
+2
View File
@@ -29,6 +29,8 @@ Dockerfile 是一个文本文件,其内包含了一条条的 **指令 (Instruc
Dockerfile 一般分为四部分基础镜像信息维护者信息镜像操作指令和容器启动时执行指令 Dockerfile 一般分为四部分基础镜像信息维护者信息镜像操作指令和容器启动时执行指令
可运行 `docker buildx build --check` 校验的[完整 Dockerfile 示例](../examples/validated/dockerfile/Dockerfile)与配套 `index.html` 保存在仓库中章节内的指令片段用于聚焦语法需要完整构建上下文时应使用这份示例
### 指令详解 ### 指令详解
本章将详细讲解 Dockerfile 中的各个指令 本章将详细讲解 Dockerfile 中的各个指令
+2
View File
@@ -4,6 +4,8 @@
默认的模板文件名称为 `compose.yaml` (也兼容 `docker-compose.yml` 等历史文件名)格式为 YAML 默认的模板文件名称为 `compose.yaml` (也兼容 `docker-compose.yml` 等历史文件名)格式为 YAML
本书同时维护一份可直接运行 `docker compose config` 校验的[完整 Compose 示例](../examples/validated/compose/compose.yaml)章节中的片段用于解释单个字段需要复制完整文件时应以该示例为准
```yaml ```yaml
services: services:
webapp: webapp:
+2
View File
@@ -2,6 +2,8 @@
本章将通过一个具体的案例部署一个 Nginx 网站并为其配置 Service来串联前面学到的知识 本章将通过一个具体的案例部署一个 Nginx 网站并为其配置 Service来串联前面学到的知识
Deployment Service [合并清单](../examples/validated/kubernetes/web.yaml)会通过严格模式的 `kubeconform` 校验下面按步骤拆开讲解复制完整清单时应以仓库中的合并版本为准
开始前请先准备好可用的 Kubernetes 集群和 `kubectl` 上下文你可以先完成 [14.3 Docker Desktop](../14_kubernetes_setup/14.3_docker-desktop.md) [14.4 Kind](../14_kubernetes_setup/14.4_kind.md)并确认 开始前请先准备好可用的 Kubernetes 集群和 `kubectl` 上下文你可以先完成 [14.3 Docker Desktop](../14_kubernetes_setup/14.3_docker-desktop.md) [14.4 Kind](../14_kubernetes_setup/14.4_kind.md)并确认
```bash ```bash
+2
View File
@@ -34,6 +34,8 @@ jobs:
``` ```
该示例会在 GitHub Actions 中构建当前仓库的 Docker 镜像不推送到 registry 该示例会在 GitHub Actions 中构建当前仓库的 Docker 镜像不推送到 registry
仓库还提供一份经 `actionlint` 校验并将 `checkout` 固定到完整 commit SHA [可执行工作流示例](../examples/validated/github-actions/validate.yml)为了让正文突出结构下面较长的教学片段仍用主版本 tag生产工作流应采用完整 SHA
### 21.2.2 构建并推送到 Registry ### 21.2.2 构建并推送到 Registry
实际项目中通常需要在 CI 中构建镜像并推送到容器 Registry以下示例展示了多阶段构建 + 登录 + 推送的完整流程 实际项目中通常需要在 CI 中构建镜像并推送到容器 Registry以下示例展示了多阶段构建 + 登录 + 推送的完整流程
+3
View File
@@ -0,0 +1,3 @@
# 经自动验证的示例
本目录保存书中四类关键示例的单一真相源ComposeDockerfileKubernetes 清单和 GitHub Actions 工作流`tools/test_examples.py` 会调用这些工具各自的原生校验命令CI 中缺少任一工具都会失败本地环境缺少工具则明确报告 `SKIP`
+8
View File
@@ -0,0 +1,8 @@
name: docker-practice
services:
web:
image: nginx:1.28-alpine
ports:
- "8080:80"
restart: unless-stopped
+3
View File
@@ -0,0 +1,3 @@
FROM nginx:1.28-alpine
COPY --chown=nginx:nginx index.html /usr/share/nginx/html/index.html
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<title>Docker 实践指南示例</title>
</head>
<body>
<h1>Docker 实践指南</h1>
</body>
</html>
@@ -0,0 +1,43 @@
name: Validate container examples
on:
push:
branches:
- master
pull_request:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pinned validators
env:
KUBECONFORM_VERSION: "0.8.0"
KUBECONFORM_SHA256: "9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883"
ACTIONLINT_VERSION: "1.7.12"
ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8"
run: |
mkdir -p "$RUNNER_TEMP/bin"
curl -fsSL --retry 3 \
"https://github.com/yannh/kubeconform/releases/download/v${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" \
-o "$RUNNER_TEMP/kubeconform.tar.gz"
echo "${KUBECONFORM_SHA256} $RUNNER_TEMP/kubeconform.tar.gz" | sha256sum -c -
tar xzf "$RUNNER_TEMP/kubeconform.tar.gz" -C "$RUNNER_TEMP/bin" kubeconform
curl -fsSL --retry 3 \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
-o "$RUNNER_TEMP/actionlint.tar.gz"
echo "${ACTIONLINT_SHA256} $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c -
tar xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP/bin" actionlint
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Validate canonical examples
run: python3 tools/test_examples.py --require-tools
+45
View File
@@ -0,0 +1,45 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 2
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.28-alpine
ports:
- name: http
containerPort: 80
readinessProbe:
httpGet:
path: /
port: http
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: nginx-service
spec:
selector:
app: nginx
ports:
- name: http
port: 80
targetPort: http
+26320
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -4,12 +4,13 @@
"description": "docker_practice", "description": "docker_practice",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@mermaid-js/mermaid-cli": "10.9.1",
"vuepress": "1.9.10", "vuepress": "1.9.10",
"vuepress-plugin-container": "^2.1.5", "vuepress-plugin-container": "^2.1.5",
"vuepress-theme-hope": "^1.0.0" "vuepress-theme-hope": "^1.0.0"
}, },
"scripts": { "scripts": {
"test": "node scripts/check_metadata.js && python3 -m unittest tools.test_prepare_pdf_sources", "test": "node scripts/check_metadata.js && python3 -m unittest tools.test_prepare_pdf_sources && python3 -m unittest discover -s tests -p 'test_*.py' && python3 tools/test_examples.py",
"mdpress:help": "mdpress --help", "mdpress:help": "mdpress --help",
"build": "rm -rf _site _site_site && mdpress build --format site --output _site && rm -rf _site && mv _site_site _site", "build": "rm -rf _site _site_site && mdpress build --format site --output _site && rm -rf _site && mv _site_site _site",
"serve": "mdpress serve", "serve": "mdpress serve",
+109
View File
@@ -0,0 +1,109 @@
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNNER = ROOT / "tools" / "test_examples.py"
FIXTURES = {
"compose": ROOT / "examples" / "validated" / "compose" / "compose.yaml",
"dockerfile": ROOT / "examples" / "validated" / "dockerfile" / "Dockerfile",
"kubernetes": ROOT / "examples" / "validated" / "kubernetes" / "web.yaml",
"github-actions": ROOT / "examples" / "validated" / "github-actions" / "validate.yml",
}
class ExampleValidationTests(unittest.TestCase):
def run_runner(self, *args, path=None, env=None):
run_env = os.environ.copy()
run_env.update(env or {})
if path is not None:
run_env["PATH"] = path
return subprocess.run(
[sys.executable, str(RUNNER), *args],
cwd=ROOT,
env=run_env,
capture_output=True,
text=True,
)
def test_canonical_fixture_files_exist(self):
missing = [name for name, path in FIXTURES.items() if not path.is_file()]
self.assertEqual(missing, [])
def test_relevant_chapters_link_to_canonical_fixtures(self):
references = {
ROOT / "11_compose" / "11.5_compose_file.md": "../examples/validated/compose/compose.yaml",
ROOT / "07_dockerfile" / "README.md": "../examples/validated/dockerfile/Dockerfile",
ROOT / "13_kubernetes_concepts" / "13.5_practice.md": "../examples/validated/kubernetes/web.yaml",
ROOT / "21_case_devops" / "21.2_github_actions.md": "../examples/validated/github-actions/validate.yml",
}
missing = []
for chapter, fixture in references.items():
if fixture not in chapter.read_text(encoding="utf-8"):
missing.append(f"{chapter.relative_to(ROOT)} -> {fixture}")
self.assertEqual(missing, [])
def test_github_actions_fixture_installs_integrity_pinned_validators(self):
text = FIXTURES["github-actions"].read_text(encoding="utf-8")
self.assertIn("KUBECONFORM_SHA256", text)
self.assertIn("ACTIONLINT_SHA256", text)
self.assertGreaterEqual(text.count("sha256sum -c -"), 2)
self.assertIn("tools/test_examples.py --require-tools", text)
def test_local_run_reports_skips_when_tools_are_unavailable(self):
result = self.run_runner(path="/usr/bin:/bin")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(result.stdout.count("SKIP"), 4, result.stdout)
self.assertIn("docker compose", result.stdout)
self.assertIn("docker buildx", result.stdout)
self.assertIn("kubeconform", result.stdout)
self.assertIn("actionlint", result.stdout)
def test_required_run_fails_when_tools_are_unavailable(self):
result = self.run_runner("--require-tools", path="/usr/bin:/bin")
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout.count("UNAVAILABLE"), 4, result.stdout)
def test_available_tools_receive_the_canonical_validation_commands(self):
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
log = tmp_path / "commands.log"
shim = tmp_path / "validator-shim"
shim.write_text(
"#!/bin/sh\n"
"printf '%s %s\\n' \"$(basename \"$0\")\" \"$*\" >> \"$COMMAND_LOG\"\n"
"exit 0\n",
encoding="utf-8",
)
shim.chmod(0o755)
for name in ("docker", "kubeconform", "actionlint"):
(tmp_path / name).symlink_to(shim)
result = self.run_runner(
"--require-tools",
path=f"{tmp_path}:/usr/bin:/bin",
env={"COMMAND_LOG": str(log)},
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
commands = log.read_text(encoding="utf-8")
self.assertIn("docker compose version", commands)
self.assertIn("docker compose -f", commands)
self.assertIn("config --quiet", commands)
self.assertIn("docker buildx version", commands)
self.assertIn("docker buildx build --check", commands)
self.assertIn("kubeconform -strict -summary", commands)
self.assertIn("actionlint", commands)
if __name__ == "__main__":
unittest.main()
+64
View File
@@ -0,0 +1,64 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = ROOT / ".github" / "workflows" / "pages.yml"
FULL_ACTION_SHA = re.compile(r"^[^@\s]+@[0-9a-f]{40}$")
class PagesWorkflowTests(unittest.TestCase):
def workflow_text(self):
self.assertTrue(WORKFLOW.is_file(), "custom Pages workflow is missing")
return WORKFLOW.read_text(encoding="utf-8")
def test_custom_pages_workflow_exists(self):
self.assertTrue(WORKFLOW.is_file(), "custom Pages workflow is missing")
def test_builds_mdpress_site_without_jekyll(self):
text = self.workflow_text()
self.assertIn("npm run build", text)
self.assertIn("MDPRESS_SHA256", text)
self.assertIn('install -m 0755 "$RUNNER_TEMP/mdpress" "$RUNNER_TEMP/bin/mdpress"', text)
self.assertIn('echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"', text)
self.assertRegex(text, r"path:\s*_site\b")
self.assertNotIn("jekyll", text.lower())
def test_build_and_deploy_jobs_have_minimum_permissions(self):
text = self.workflow_text()
self.assertRegex(
text,
r"(?ms)^ build:\n permissions:\n contents: read\n pages: read\b",
)
self.assertRegex(
text,
r"(?ms)^ deploy:.*?permissions:\n pages: write\n id-token: write\b",
)
self.assertRegex(text, r"(?ms)^ deploy:.*?needs: build\b")
self.assertIn("environment:", text)
self.assertIn("name: github-pages", text)
def test_actions_are_immutable_and_checkout_drops_credentials(self):
text = self.workflow_text()
actions = re.findall(r"\buses:\s*([^\s#]+)", text)
self.assertGreater(len(actions), 0)
self.assertTrue(all(FULL_ACTION_SHA.fullmatch(action) for action in actions), actions)
self.assertRegex(text, r"actions/checkout@[0-9a-f]{40}\s+# v\d")
self.assertRegex(
text,
r"(?ms)actions/checkout@[0-9a-f]{40}.*?with:\n\s+persist-credentials: false",
)
def test_documents_manual_pages_source_setting(self):
text = self.workflow_text()
self.assertIn("Settings > Pages > Source", text)
self.assertIn("GitHub Actions", text)
if __name__ == "__main__":
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
import hashlib
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VERIFIER = ROOT / "tools" / "verify_artifacts.py"
class VerifyArtifactsTests(unittest.TestCase):
def run_verifier(self, *args):
return subprocess.run(
[sys.executable, str(VERIFIER), *args],
cwd=ROOT,
capture_output=True,
text=True,
)
def test_verifies_html_title_and_writes_sha256_manifest(self):
with tempfile.TemporaryDirectory() as tmp:
artifact = Path(tmp) / "reader.html"
checksums = Path(tmp) / "SHA256SUMS"
artifact.write_text(
"<!doctype html><html><head><title>Docker —— 从入门到实践</title></head></html>",
encoding="utf-8",
)
result = self.run_verifier(
"--title",
"Docker —— 从入门到实践",
"--html",
str(artifact),
"--checksums",
str(checksums),
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
expected = hashlib.sha256(artifact.read_bytes()).hexdigest()
self.assertEqual(checksums.read_text(encoding="utf-8"), f"{expected} reader.html\n")
def test_rejects_missing_artifact(self):
result = self.run_verifier(
"--title",
"Docker —— 从入门到实践",
"--html",
"/does/not/exist.html",
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("does not exist", result.stderr)
def test_rejects_wrong_html_title(self):
with tempfile.TemporaryDirectory() as tmp:
artifact = Path(tmp) / "reader.html"
artifact.write_text("<title>Wrong book</title>", encoding="utf-8")
result = self.run_verifier(
"--title",
"Docker —— 从入门到实践",
"--html",
str(artifact),
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("title mismatch", result.stderr)
def test_accepts_mdpress_site_title_with_page_suffix(self):
with tempfile.TemporaryDirectory() as tmp:
site = Path(tmp) / "_site"
site.mkdir()
(site / "index.html").write_text(
"<title>Docker 从入门到实践 - Docker 从入门到实践</title>",
encoding="utf-8",
)
result = self.run_verifier(
"--title",
"Docker 从入门到实践",
"--site",
str(site),
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
import json
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WORKFLOW_DIR = ROOT / ".github" / "workflows"
FULL_ACTION_SHA = re.compile(r"^[^@\s]+@[0-9a-f]{40}$")
class WorkflowSecurityTests(unittest.TestCase):
@staticmethod
def workflows():
return sorted(WORKFLOW_DIR.glob("*.y*ml"))
def test_all_actions_are_immutable_with_version_comments(self):
failures = []
for workflow in self.workflows():
for number, line in enumerate(workflow.read_text(encoding="utf-8").splitlines(), 1):
match = re.search(r"\buses:\s*([^\s#]+)(?:\s+#\s*(\S+))?", line)
if not match:
continue
action, version = match.groups()
if not FULL_ACTION_SHA.fullmatch(action) or not version or not version.startswith("v"):
failures.append(f"{workflow.name}:{number}: {line.strip()}")
self.assertEqual(failures, [])
def test_checkout_never_persists_credentials(self):
failures = []
for workflow in self.workflows():
lines = workflow.read_text(encoding="utf-8").splitlines()
for index, line in enumerate(lines):
if "uses: actions/checkout@" not in line:
continue
step = "\n".join(lines[index : index + 8])
if "persist-credentials: false" not in step:
failures.append(f"{workflow.name}:{index + 1}")
self.assertEqual(failures, [])
def test_every_workflow_declares_permissions(self):
failures = []
for workflow in self.workflows():
text = workflow.read_text(encoding="utf-8")
before_jobs = text.split("\njobs:", 1)[0]
if not re.search(r"(?m)^permissions:", before_jobs):
failures.append(workflow.name)
self.assertEqual(failures, [])
def test_release_and_preview_separate_read_only_builds_from_writes(self):
expectations = {
"auto-release.yml": ("release",),
"preview-pdf.yml": ("publish",),
}
for name, write_jobs in expectations.items():
text = (WORKFLOW_DIR / name).read_text(encoding="utf-8")
self.assertRegex(text, r"(?ms)^ build:\n permissions:\n contents: read\b", name)
for job in write_jobs:
self.assertRegex(
text,
rf"(?ms)^ {job}:.*?permissions:\n contents: write\b.*?needs: build\b",
name,
)
def test_downloads_dependencies_and_link_checker_are_integrity_pinned(self):
combined = "\n".join(path.read_text(encoding="utf-8") for path in self.workflows())
link_text = (WORKFLOW_DIR / "check-link.yml").read_text(encoding="utf-8")
self.assertNotRegex(combined, r"npm install\s+-g\s+@mermaid-js/mermaid-cli")
self.assertIn("PANDOC_SHA256", combined)
self.assertIn("KUBECONFORM_SHA256", combined)
self.assertIn("ACTIONLINT_SHA256", combined)
self.assertRegex(link_text, r"dkhamsing/awesome_bot@sha256:[0-9a-f]{64}")
def test_mermaid_is_exact_and_lockfile_backed(self):
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
version = package["devDependencies"]["@mermaid-js/mermaid-cli"]
self.assertRegex(version, r"^\d+\.\d+\.\d+$")
self.assertTrue((ROOT / "package-lock.json").is_file())
ignored = {
line.strip()
for line in (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
self.assertNotIn("package-lock.json", ignored)
self.assertTrue(all("npm ci" in path.read_text(encoding="utf-8") for path in self.workflows() if path.name != "check-link.yml" and path.name != "dependabot-automerge.yml"))
def test_artifacts_are_smoke_tested_and_html_failures_are_not_silent(self):
verifier = ROOT / "tools" / "verify_artifacts.py"
self.assertTrue(verifier.is_file())
for name in ("auto-release.yml", "ci.yaml", "preview-pdf.yml"):
text = (WORKFLOW_DIR / name).read_text(encoding="utf-8")
self.assertIn("tools/verify_artifacts.py", text, name)
self.assertIn("SHA256SUMS", text, name)
auto_release = (WORKFLOW_DIR / "auto-release.yml").read_text(encoding="utf-8")
self.assertNotIn("continue-on-error: true", auto_release)
if __name__ == "__main__":
unittest.main()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Validate the book's canonical container examples with their native tools."""
import argparse
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
ROOT = Path(__file__).resolve().parents[1]
FIXTURE_ROOT = ROOT / "examples" / "validated"
@dataclass(frozen=True)
class Check:
name: str
executable: str
command: List[str]
probe: Optional[List[str]] = None
CHECKS = (
Check(
"docker compose",
"docker",
[
"docker",
"compose",
"-f",
str(FIXTURE_ROOT / "compose" / "compose.yaml"),
"config",
"--quiet",
],
["docker", "compose", "version"],
),
Check(
"docker buildx",
"docker",
[
"docker",
"buildx",
"build",
"--check",
"--file",
str(FIXTURE_ROOT / "dockerfile" / "Dockerfile"),
str(FIXTURE_ROOT / "dockerfile"),
],
["docker", "buildx", "version"],
),
Check(
"kubeconform",
"kubeconform",
[
"kubeconform",
"-strict",
"-summary",
"-kubernetes-version",
"1.31.0",
str(FIXTURE_ROOT / "kubernetes" / "web.yaml"),
],
),
Check(
"actionlint",
"actionlint",
["actionlint", str(FIXTURE_ROOT / "github-actions" / "validate.yml")],
),
)
def run_command(command):
return subprocess.run(command, cwd=ROOT, capture_output=True, text=True)
def unavailable(check, require_tools, detail):
status = "UNAVAILABLE" if require_tools else "SKIP"
print(f"[{status}] {check.name}: {detail}")
return require_tools
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--require-tools",
action="store_true",
help="fail instead of skipping when a validator is unavailable (for CI)",
)
args = parser.parse_args()
failed = False
for check in CHECKS:
if shutil.which(check.executable) is None:
failed = unavailable(check, args.require_tools, f"{check.executable} not found") or failed
continue
if check.probe is not None:
probe = run_command(check.probe)
if probe.returncode != 0:
detail = (probe.stderr or probe.stdout or "plugin probe failed").strip()
failed = unavailable(check, args.require_tools, detail) or failed
continue
result = run_command(check.command)
if result.returncode != 0:
print(f"[FAIL] {check.name}")
print((result.stderr or result.stdout).strip())
failed = True
continue
print(f"[PASS] {check.name}")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
+16 -2
View File
@@ -2,12 +2,24 @@ import tempfile
import unittest import unittest
from base64 import b64encode from base64 import b64encode
from pathlib import Path from pathlib import Path
import subprocess
from tools.prepare_pdf_sources import normalize_markdown_asset_paths, prepare_pdf_sources import sys
class PreparePdfSourcesTest(unittest.TestCase): class PreparePdfSourcesTest(unittest.TestCase):
def test_module_imports_with_running_python(self):
result = subprocess.run(
[sys.executable, "-c", "import tools.prepare_pdf_sources"],
cwd=Path(__file__).resolve().parents[1],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_normalizes_relative_markdown_and_html_image_paths(self): def test_normalizes_relative_markdown_and_html_image_paths(self):
from tools.prepare_pdf_sources import normalize_markdown_asset_paths
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
book_dir = Path(tmp) book_dir = Path(tmp)
(book_dir / "_images").mkdir() (book_dir / "_images").mkdir()
@@ -31,6 +43,8 @@ class PreparePdfSourcesTest(unittest.TestCase):
self.assertEqual(count, 2) self.assertEqual(count, 2)
def test_prepares_temp_tree_without_mutating_source_markdown(self): def test_prepares_temp_tree_without_mutating_source_markdown(self):
from tools.prepare_pdf_sources import prepare_pdf_sources
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "source" source = Path(tmp) / "source"
target = Path(tmp) / "target" target = Path(tmp) / "target"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Smoke-test built book artifacts and write a portable SHA-256 manifest."""
import argparse
import hashlib
import html
import re
import shutil
import subprocess
import sys
from pathlib import Path
def fail(message):
print(f"artifact verification failed: {message}", file=sys.stderr)
raise SystemExit(1)
def normalized_title(value):
return " ".join(html.unescape(value).split())
def require_file(path):
if not path.is_file():
fail(f"{path} does not exist or is not a file")
if path.stat().st_size == 0:
fail(f"{path} is empty")
def verify_html(path, expected_title):
require_file(path)
text = path.read_text(encoding="utf-8")
match = re.search(r"<title(?:\s[^>]*)?>(.*?)</title>", text, re.IGNORECASE | re.DOTALL)
actual = normalized_title(match.group(1)) if match else ""
expected = normalized_title(expected_title)
accepted = actual == expected or actual.startswith(f"{expected} - ") or actual.endswith(f" - {expected}")
if not accepted:
fail(f"{path} title mismatch: expected {expected_title!r}, got {actual!r}")
def command_output(command):
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
fail(f"command failed ({' '.join(command)}): {(result.stderr or result.stdout).strip()}")
return result.stdout
def verify_pdf(path, expected_title):
require_file(path)
if not path.read_bytes().startswith(b"%PDF-"):
fail(f"{path} does not have a PDF signature")
if shutil.which("pdfinfo") is None or shutil.which("pdftotext") is None:
fail("pdfinfo and pdftotext are required for PDF title verification")
metadata = command_output(["pdfinfo", str(path)])
title_match = re.search(r"(?m)^Title:\s*(.*)$", metadata)
metadata_title = normalized_title(title_match.group(1)) if title_match else ""
expected = normalized_title(expected_title)
if metadata_title == expected:
return
first_pages = normalized_title(
command_output(["pdftotext", "-f", "1", "-l", "2", str(path), "-"])
)
if expected not in first_pages:
fail(
f"{path} title mismatch: expected {expected_title!r}; "
f"PDF metadata title was {metadata_title!r}"
)
def write_checksums(paths, destination):
destination.parent.mkdir(parents=True, exist_ok=True)
lines = []
for path in sorted(paths, key=lambda item: item.name):
if path.parent.resolve() != destination.parent.resolve():
fail(f"{path} must be beside checksum manifest {destination}")
digest = hashlib.sha256(path.read_bytes()).hexdigest()
lines.append(f"{digest} {path.name}\n")
destination.write_text("".join(lines), encoding="utf-8")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--title", required=True)
parser.add_argument("--pdf", type=Path)
parser.add_argument("--html", type=Path)
parser.add_argument("--site", type=Path)
parser.add_argument("--checksums", type=Path)
args = parser.parse_args()
artifacts = []
if args.pdf:
verify_pdf(args.pdf, args.title)
artifacts.append(args.pdf)
if args.html:
verify_html(args.html, args.title)
artifacts.append(args.html)
if args.site:
verify_html(args.site / "index.html", args.title)
if not artifacts and not args.site:
parser.error("at least one of --pdf, --html, or --site is required")
if args.checksums:
if not artifacts:
fail("a checksum manifest requires at least one file artifact")
write_checksums(artifacts, args.checksums)
for path in artifacts:
print(f"verified artifact: {path}")
if args.site:
print(f"verified site: {args.site / 'index.html'}")
if args.checksums:
print(f"wrote checksums: {args.checksums}")
return 0
if __name__ == "__main__":
raise SystemExit(main())