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
213 changes: 213 additions & 0 deletions .github/workflows/theme-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
name: Theme Release

on:
workflow_dispatch:
inputs:
operation:
description: Publish a new version or recover a missing source tag
required: true
default: publish
type: choice
options:
- publish
- recover-tag
version:
description: Exact @aeei/docusaurus-theme version from package.json
required: true
type: string
confirmation:
description: Type the operation-specific confirmation shown in UPSTREAM.md
required: true
type: string
source_sha:
description: Original 40-character source commit for retry or recover-tag
required: false
type: string

permissions: {}

concurrency:
group: theme-release
cancel-in-progress: false

jobs:
prepare:
name: Build immutable theme artifact
if: ${{ github.repository == 'aeei/docusaurus-theme' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
outputs:
file: ${{ steps.package.outputs.file }}
integrity: ${{ steps.package.outputs.integrity }}
version: ${{ steps.intent.outputs.version }}
source_sha: ${{ steps.intent.outputs.source_sha }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ inputs.source_sha || github.sha }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.18.0
cache: yarn
- run: npm install --global npm@11.16.0

- name: Validate explicit release intent
id: intent
env:
OPERATION: ${{ inputs.operation }}
VERSION: ${{ inputs.version }}
CONFIRMATION: ${{ inputs.confirmation }}
INPUT_SOURCE_SHA: ${{ inputs.source_sha }}
run: |
actual=$(node -p "require('./packages/docusaurus-theme/package.json').version")
test "$actual" = "$VERSION"
case "$OPERATION" in
publish)
expected="publish @aeei/docusaurus-theme@${VERSION}"
source_sha="${INPUT_SOURCE_SHA:-$GITHUB_SHA}"
printf '%s' "$source_sha" | grep -Eq '^[0-9a-f]{40}$'
git fetch origin main --no-tags
git merge-base --is-ancestor "$source_sha" origin/main
test "$(git rev-parse HEAD)" = "$source_sha"
;;
recover-tag)
expected="recover theme-v${VERSION} for @aeei/docusaurus-theme@${VERSION}"
printf '%s' "$INPUT_SOURCE_SHA" | grep -Eq '^[0-9a-f]{40}$'
source_sha="$INPUT_SOURCE_SHA"
git fetch origin main --no-tags
git merge-base --is-ancestor "$source_sha" origin/main
test "$(git rev-parse HEAD)" = "$source_sha"
;;
*) echo "Unsupported operation: $OPERATION" >&2; exit 1 ;;
esac
test "$CONFIRMATION" = "$expected"
tag_ref=$(git ls-remote --tags origin "refs/tags/theme-v${VERSION}" | awk '{print $1}')
tag_commit=$(git ls-remote --tags origin "refs/tags/theme-v${VERSION}^{}" | awk '{print $1}')
if [ -n "$tag_ref" ]; then
test -n "$tag_commit"
test "$tag_commit" = "$source_sha"
fi
published=$(npm view "@aeei/docusaurus-theme@${VERSION}" dist.integrity 2>/dev/null || true)
if [ "$OPERATION" = publish ]; then
test -z "$published"
else
test -n "$published"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"

- run: yarn install --frozen-lockfile --ignore-scripts
- run: yarn workspace @aeei/docusaurus-theme build
- name: Theme release contracts
run: >-
NODE_OPTIONS=--experimental-vm-modules yarn jest
packages/docusaurus-theme/src/package-contract.test.ts
packages/docusaurus-theme/src/legal-notices.test.ts
examples/docs-starter/config-contract.test.ts
--runInBand
- run: yarn workspace @aeei/docs-starter build

- name: Pack immutable artifact
id: package
working-directory: packages/docusaurus-theme
env:
VERSION: ${{ inputs.version }}
run: |
npm pack --json > /tmp/aeei-theme-pack.json
file=$(node -p "JSON.parse(require('fs').readFileSync('/tmp/aeei-theme-pack.json','utf8'))[0].filename")
packed_name=$(tar -xOzf "$file" package/package.json | node -e "let s=''; process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).name))")
packed_version=$(tar -xOzf "$file" package/package.json | node -e "let s=''; process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
test "$packed_name" = "@aeei/docusaurus-theme"
test "$packed_version" = "$VERSION"
mv "$file" "$GITHUB_WORKSPACE/$file"
integrity=$(node -e "const fs=require('fs'),crypto=require('crypto'); const bytes=fs.readFileSync(process.argv[1]); process.stdout.write('sha512-'+crypto.createHash('sha512').update(bytes).digest('base64'))" "$GITHUB_WORKSPACE/$file")
echo "file=$file" >> "$GITHUB_OUTPUT"
echo "integrity=$integrity" >> "$GITHUB_OUTPUT"
printf '%s %s\n' "$integrity" "$file"

- name: Verify recovery artifact
if: ${{ inputs.operation == 'recover-tag' }}
env:
VERSION: ${{ inputs.version }}
INTEGRITY: ${{ steps.package.outputs.integrity }}
run: test "$(npm view "@aeei/docusaurus-theme@${VERSION}" dist.integrity)" = "$INTEGRITY"

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: aeei-docusaurus-theme-${{ inputs.version }}
path: ${{ steps.package.outputs.file }}
if-no-files-found: error
retention-days: 7

release:
name: Publish and tag approved artifact
needs: prepare
runs-on: ubuntu-latest
timeout-minutes: 10
environment: theme-release
permissions:
contents: write
id-token: write
steps:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.18.0
registry-url: https://registry.npmjs.org
- run: npm install --global npm@11.16.0
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: aeei-docusaurus-theme-${{ needs.prepare.outputs.version }}

- name: Verify downloaded artifact
env:
FILE: ${{ needs.prepare.outputs.file }}
INTEGRITY: ${{ needs.prepare.outputs.integrity }}
run: |
actual=$(node -e "const fs=require('fs'),crypto=require('crypto'); const bytes=fs.readFileSync(process.argv[1]); process.stdout.write('sha512-'+crypto.createHash('sha512').update(bytes).digest('base64'))" "$FILE")
test "$actual" = "$INTEGRITY"

- name: Reserve or verify immutable source tag
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.prepare.outputs.version }}
REPOSITORY: ${{ github.repository }}
TARGET_SHA: ${{ needs.prepare.outputs.source_sha }}
run: |
if ref=$(gh api "repos/${REPOSITORY}/git/ref/tags/theme-v${VERSION}" 2>/dev/null); then
tag_object=$(printf '%s' "$ref" | node -e "let s=''; process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).object.sha))")
existing_target=$(gh api "repos/${REPOSITORY}/git/tags/${tag_object}" --jq .object.sha)
test "$existing_target" = "$TARGET_SHA"
else
tag_object=$(gh api --method POST "repos/${REPOSITORY}/git/tags" \
-f tag="theme-v${VERSION}" \
-f message="@aeei/docusaurus-theme ${VERSION}" \
-f object="$TARGET_SHA" \
-f type=commit \
--jq .sha)
gh api --method POST "repos/${REPOSITORY}/git/refs" \
-f ref="refs/tags/theme-v${VERSION}" \
-f sha="$tag_object"
fi

- name: Publish theme through npm trusted publishing
if: ${{ inputs.operation == 'publish' }}
env:
FILE: ${{ needs.prepare.outputs.file }}
run: npm publish "$FILE" --access public

- name: Verify registry integrity
env:
VERSION: ${{ needs.prepare.outputs.version }}
INTEGRITY: ${{ needs.prepare.outputs.integrity }}
run: |
registry_integrity=""
for _ in $(seq 1 12); do
registry_integrity=$(npm view "@aeei/docusaurus-theme@${VERSION}" dist.integrity 2>/dev/null || true)
[ -n "$registry_integrity" ] && break
sleep 5
done
test "$registry_integrity" = "$INTEGRITY"
9 changes: 9 additions & 0 deletions UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ This repository is a maintained fork of [`PaloAltoNetworks/docusaurus-openapi-do
6. Compare the packed `@aeei/docusaurus-theme` artifact with the current release. If its bytes change, bump the package version and update every vendored consumer artifact; never reuse a version for a different archive.
7. Merge only after independent review and green CI.

## Theme release

- Deck consumes a repository-owned tarball; npm is a secondary distribution channel.
- npm publication is manual through `.github/workflows/theme-release.yml` and the protected `theme-release` environment.
- npm trusted publishing must bind `@aeei/docusaurus-theme` to repository `aeei/docusaurus-theme`, workflow `theme-release.yml`, and environment `theme-release`.
- A new publish requires `publish @aeei/docusaurus-theme@X.Y.Z`. The privileged job atomically reserves or verifies `theme-vX.Y.Z` for the exact source SHA before npm publication, so a competing tag aborts before publish. If npm fails after reservation, retry `publish` with that original 40-character commit as `source_sha`.
- `recover-tag` is only for an npm version published outside this workflow without its source tag. It requires the original `source_sha` and `recover theme-vX.Y.Z for @aeei/docusaurus-theme@X.Y.Z`; recovery checks out that exact main ancestor and creates a tag only when its clean rebuild exactly matches npm `dist.integrity`.
- The privileged release job receives only the prebuilt tarball, verifies its SHA-512 integrity and source tag, publishes only that file when needed, then verifies registry integrity.

## Baseline

- Last integrated upstream release: `v5.1.3`
Expand Down
60 changes: 60 additions & 0 deletions examples/docs-starter/config-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const packageJson = JSON.parse(
const demoPackageJson = JSON.parse(
fs.readFileSync(path.join(__dirname, "../../demo/package.json"), "utf8")
);
const themeReleaseWorkflow = fs.readFileSync(
path.join(__dirname, "../../.github/workflows/theme-release.yml"),
"utf8"
);

it("uses the Pages project URL and official Mermaid integration", () => {
expect(config).toContain('url: "https://aeei.github.io"');
Expand Down Expand Up @@ -47,6 +51,62 @@ it("keeps one Docusaurus runtime version across the starter and workspace", () =
);
});

it("publishes only one immutable AEEI theme artifact after explicit approval", () => {
const prepareJob = themeReleaseWorkflow
.split("\n prepare:")[1]
.split("\n release:")[0];
const releaseJob = themeReleaseWorkflow.split("\n release:")[1];

expect(themeReleaseWorkflow).toContain("workflow_dispatch:");
expect(themeReleaseWorkflow).not.toMatch(/^\s*push:/m);
expect(themeReleaseWorkflow).toContain("github.ref == 'refs/heads/main'");
expect(themeReleaseWorkflow).toContain("environment: theme-release");
expect(themeReleaseWorkflow).toContain("node-version: 24.18.0");
expect(themeReleaseWorkflow).toContain("npm@11.16.0");
expect(themeReleaseWorkflow).toContain("source_sha:");
expect(themeReleaseWorkflow).toContain("fetch-depth: 0");
expect(themeReleaseWorkflow).toContain(
"ref: ${{ inputs.source_sha || github.sha }}"
);
expect(themeReleaseWorkflow).toContain("git merge-base --is-ancestor");
expect(themeReleaseWorkflow).toContain(
'test "$(git rev-parse HEAD)" = "$source_sha"'
);
expect(themeReleaseWorkflow).toContain(
"TARGET_SHA: ${{ needs.prepare.outputs.source_sha }}"
);
expect(themeReleaseWorkflow).toContain(
'expected="publish @aeei/docusaurus-theme@${VERSION}"'
);
expect(themeReleaseWorkflow).toContain(
'expected="recover theme-v${VERSION} for @aeei/docusaurus-theme@${VERSION}"'
);
expect(prepareJob).toContain("contents: read");
expect(prepareJob).toContain("persist-credentials: false");
expect(prepareJob).not.toContain("id-token: write");
expect(prepareJob).toContain("npm pack --json");
expect(prepareJob).toContain("package/package.json");
expect(prepareJob).toContain(
'test "$packed_name" = "@aeei/docusaurus-theme"'
);
expect(prepareJob).toContain('test "$packed_version" = "$VERSION"');
expect(prepareJob).toContain("actions/upload-artifact@");
expect(releaseJob).not.toContain("actions/checkout@");
expect(releaseJob).toContain("contents: write");
expect(releaseJob).toContain("id-token: write");
expect(releaseJob).toContain("actions/download-artifact@");
expect(releaseJob).toContain('npm publish "$FILE" --access public');
expect(
releaseJob.indexOf("Reserve or verify immutable source tag")
).toBeLessThan(releaseJob.indexOf("npm publish"));
expect(releaseJob).toContain("dist.integrity");
expect(releaseJob).toContain("repos/${REPOSITORY}/git/tags");
expect(themeReleaseWorkflow).not.toContain("NPM_TOKEN");
expect(themeReleaseWorkflow).not.toContain("NODE_AUTH_TOKEN");
expect(themeReleaseWorkflow).not.toContain("lerna publish");
expect(themeReleaseWorkflow).not.toContain("yarn release:publish");
});

it("enables local search and the theme-native Copy Page control", () => {
expect(config).toContain('{ search: "local", copyPage: true }');
expect(config).toContain('"docusaurus-plugin-copy-page-button"');
Expand Down