diff --git a/.github/workflows/theme-release.yml b/.github/workflows/theme-release.yml new file mode 100644 index 000000000..ec071e5fc --- /dev/null +++ b/.github/workflows/theme-release.yml @@ -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" diff --git a/UPSTREAM.md b/UPSTREAM.md index 805d1aa2f..5a2051d51 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -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` diff --git a/examples/docs-starter/config-contract.test.ts b/examples/docs-starter/config-contract.test.ts index cc3bed60e..5949a22bd 100644 --- a/examples/docs-starter/config-contract.test.ts +++ b/examples/docs-starter/config-contract.test.ts @@ -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"'); @@ -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"');