From 0b95d465539e040d18f341b8dbecf87af1642679 Mon Sep 17 00:00:00 2001 From: aeei <18022843+aeei@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:42:27 +0900 Subject: [PATCH 1/4] ci(theme): add guarded trusted release workflow --- .github/workflows/theme-release.yml | 80 +++++++++++++++++++ UPSTREAM.md | 7 ++ examples/docs-starter/config-contract.test.ts | 21 +++++ 3 files changed, 108 insertions(+) create mode 100644 .github/workflows/theme-release.yml diff --git a/.github/workflows/theme-release.yml b/.github/workflows/theme-release.yml new file mode 100644 index 000000000..a2ad01930 --- /dev/null +++ b/.github/workflows/theme-release.yml @@ -0,0 +1,80 @@ +name: Theme Release + +on: + workflow_dispatch: + inputs: + version: + description: Exact @aeei/docusaurus-theme version from package.json + required: true + type: string + confirmation: + description: Type "publish @aeei/docusaurus-theme@" + required: true + type: string + +permissions: {} + +concurrency: + group: theme-release + cancel-in-progress: false + +jobs: + publish: + name: Publish @aeei/docusaurus-theme + if: ${{ github.repository == 'aeei/docusaurus-theme' && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: theme-release + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: yarn + registry-url: https://registry.npmjs.org + + - name: Validate explicit release intent + env: + VERSION: ${{ inputs.version }} + CONFIRMATION: ${{ inputs.confirmation }} + run: | + actual=$(node -p "require('./packages/docusaurus-theme/package.json').version") + expected="publish @aeei/docusaurus-theme@${VERSION}" + test "$actual" = "$VERSION" + test "$CONFIRMATION" = "$expected" + if git rev-parse "refs/tags/theme-v${VERSION}" >/dev/null 2>&1; then + echo "theme-v${VERSION} already exists" >&2 + exit 1 + fi + if npm view "@aeei/docusaurus-theme@${VERSION}" version >/dev/null 2>&1; then + echo "@aeei/docusaurus-theme@${VERSION} is already published" >&2 + exit 1 + fi + + - 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: Package contents + working-directory: packages/docusaurus-theme + run: npm pack --dry-run + - name: Publish theme through npm trusted publishing + working-directory: packages/docusaurus-theme + run: npm publish --access public + - name: Tag published source + env: + VERSION: ${{ inputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "theme-v${VERSION}" -m "@aeei/docusaurus-theme ${VERSION}" + git push origin "theme-v${VERSION}" diff --git a/UPSTREAM.md b/UPSTREAM.md index 805d1aa2f..4a48cc44b 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -24,6 +24,13 @@ 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`. +- The workflow requires the exact package version and confirmation text, publishes only `@aeei/docusaurus-theme`, then tags the source as `theme-vX.Y.Z`. + ## 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..4f2030592 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,23 @@ it("keeps one Docusaurus runtime version across the starter and workspace", () = ); }); +it("publishes only the AEEI theme through an explicitly confirmed manual release", () => { + 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("packages/docusaurus-theme"); + expect(themeReleaseWorkflow).toContain( + 'expected="publish @aeei/docusaurus-theme@${VERSION}"' + ); + expect(themeReleaseWorkflow).toContain("id-token: write"); + expect(themeReleaseWorkflow).toContain("npm publish --access public"); + 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"'); From 4b68453ab8b265e804e20727741223df912e83bc Mon Sep 17 00:00:00 2001 From: aeei <18022843+aeei@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:50:23 +0900 Subject: [PATCH 2/4] fix(release): isolate publish and protect artifact integrity --- .github/workflows/theme-release.yml | 140 +++++++++++++++--- UPSTREAM.md | 3 +- examples/docs-starter/config-contract.test.ts | 27 +++- 3 files changed, 142 insertions(+), 28 deletions(-) diff --git a/.github/workflows/theme-release.yml b/.github/workflows/theme-release.yml index a2ad01930..1c80b75dc 100644 --- a/.github/workflows/theme-release.yml +++ b/.github/workflows/theme-release.yml @@ -3,12 +3,20 @@ 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 "publish @aeei/docusaurus-theme@" + description: Type the operation-specific confirmation shown in UPSTREAM.md required: true type: string @@ -19,40 +27,53 @@ concurrency: cancel-in-progress: false jobs: - publish: - name: Publish @aeei/docusaurus-theme + prepare: + name: Build immutable theme artifact if: ${{ github.repository == 'aeei/docusaurus-theme' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 30 - environment: theme-release permissions: - contents: write - id-token: write + contents: read + outputs: + file: ${{ steps.package.outputs.file }} + integrity: ${{ steps.package.outputs.integrity }} + version: ${{ steps.intent.outputs.version }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 24 + node-version: 24.18.0 cache: yarn - registry-url: https://registry.npmjs.org + - 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 }} run: | actual=$(node -p "require('./packages/docusaurus-theme/package.json').version") - expected="publish @aeei/docusaurus-theme@${VERSION}" test "$actual" = "$VERSION" + case "$OPERATION" in + publish) expected="publish @aeei/docusaurus-theme@${VERSION}" ;; + recover-tag) expected="recover theme-v${VERSION} for @aeei/docusaurus-theme@${VERSION}" ;; + *) echo "Unsupported operation: $OPERATION" >&2; exit 1 ;; + esac test "$CONFIRMATION" = "$expected" - if git rev-parse "refs/tags/theme-v${VERSION}" >/dev/null 2>&1; then + if git ls-remote --exit-code --tags origin "refs/tags/theme-v${VERSION}" >/dev/null 2>&1; then echo "theme-v${VERSION} already exists" >&2 exit 1 fi - if npm view "@aeei/docusaurus-theme@${VERSION}" version >/dev/null 2>&1; then - echo "@aeei/docusaurus-theme@${VERSION} is already published" >&2 - exit 1 + 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" - run: yarn install --frozen-lockfile --ignore-scripts - run: yarn workspace @aeei/docusaurus-theme build @@ -64,17 +85,90 @@ jobs: examples/docs-starter/config-contract.test.ts --runInBand - run: yarn workspace @aeei/docs-starter build - - name: Package contents - working-directory: packages/docusaurus-theme - run: npm pack --dry-run - - name: Publish theme through npm trusted publishing + + - name: Pack immutable artifact + id: package working-directory: packages/docusaurus-theme - run: npm publish --access public - - name: Tag published source + run: | + file=$(npm pack --silent) + 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: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "theme-v${VERSION}" -m "@aeei/docusaurus-theme ${VERSION}" - git push origin "theme-v${VERSION}" + 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: 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 and create immutable source tag + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.prepare.outputs.version }} + INTEGRITY: ${{ needs.prepare.outputs.integrity }} + REPOSITORY: ${{ github.repository }} + TARGET_SHA: ${{ github.sha }} + 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" + if gh api "repos/${REPOSITORY}/git/ref/tags/theme-v${VERSION}" >/dev/null 2>&1; then + echo "theme-v${VERSION} already exists" >&2 + exit 1 + fi + tag_sha=$(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_sha" diff --git a/UPSTREAM.md b/UPSTREAM.md index 4a48cc44b..a6e7d384f 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -29,7 +29,8 @@ This repository is a maintained fork of [`PaloAltoNetworks/docusaurus-openapi-do - 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`. -- The workflow requires the exact package version and confirmation text, publishes only `@aeei/docusaurus-theme`, then tags the source as `theme-vX.Y.Z`. +- A new publish requires `publish @aeei/docusaurus-theme@X.Y.Z`. If npm succeeds but tag creation fails, rerun with `recover-tag` and `recover theme-vX.Y.Z for @aeei/docusaurus-theme@X.Y.Z`; recovery creates a tag only when a clean rebuild exactly matches npm `dist.integrity`. +- The privileged release job receives only the prebuilt tarball, verifies its SHA-512 integrity, publishes only that file, verifies registry integrity, then creates `theme-vX.Y.Z` through the GitHub API. ## Baseline diff --git a/examples/docs-starter/config-contract.test.ts b/examples/docs-starter/config-contract.test.ts index 4f2030592..fdcba1163 100644 --- a/examples/docs-starter/config-contract.test.ts +++ b/examples/docs-starter/config-contract.test.ts @@ -51,17 +51,36 @@ it("keeps one Docusaurus runtime version across the starter and workspace", () = ); }); -it("publishes only the AEEI theme through an explicitly confirmed manual release", () => { +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("packages/docusaurus-theme"); + expect(themeReleaseWorkflow).toContain("node-version: 24.18.0"); + expect(themeReleaseWorkflow).toContain("npm@11.16.0"); expect(themeReleaseWorkflow).toContain( 'expected="publish @aeei/docusaurus-theme@${VERSION}"' ); - expect(themeReleaseWorkflow).toContain("id-token: write"); - expect(themeReleaseWorkflow).toContain("npm publish --access public"); + 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 --silent"); + 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).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"); From 1d78210e4f4000c20deec8faaa72052addc481e8 Mon Sep 17 00:00:00 2001 From: aeei <18022843+aeei@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:56:53 +0900 Subject: [PATCH 3/4] fix(release): verify package identity and recovery source --- .github/workflows/theme-release.yml | 35 ++++++++++++++++--- UPSTREAM.md | 2 +- examples/docs-starter/config-contract.test.ts | 12 ++++++- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.github/workflows/theme-release.yml b/.github/workflows/theme-release.yml index 1c80b75dc..be9c3a544 100644 --- a/.github/workflows/theme-release.yml +++ b/.github/workflows/theme-release.yml @@ -19,6 +19,10 @@ on: description: Type the operation-specific confirmation shown in UPSTREAM.md required: true type: string + source_sha: + description: Original 40-character publish commit; required only for recover-tag + required: false + type: string permissions: {} @@ -38,10 +42,13 @@ jobs: 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.operation == 'recover-tag' && inputs.source_sha || github.sha }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24.18.0 @@ -54,12 +61,24 @@ jobs: 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}" ;; - recover-tag) expected="recover theme-v${VERSION} for @aeei/docusaurus-theme@${VERSION}" ;; + publish) + expected="publish @aeei/docusaurus-theme@${VERSION}" + source_sha="$GITHUB_SHA" + test -z "$INPUT_SOURCE_SHA" || test "$INPUT_SOURCE_SHA" = "$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" @@ -74,6 +93,7 @@ jobs: 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 @@ -89,8 +109,15 @@ jobs: - name: Pack immutable artifact id: package working-directory: packages/docusaurus-theme + env: + VERSION: ${{ inputs.version }} run: | - file=$(npm pack --silent) + 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" @@ -150,7 +177,7 @@ jobs: VERSION: ${{ needs.prepare.outputs.version }} INTEGRITY: ${{ needs.prepare.outputs.integrity }} REPOSITORY: ${{ github.repository }} - TARGET_SHA: ${{ github.sha }} + TARGET_SHA: ${{ needs.prepare.outputs.source_sha }} run: | registry_integrity="" for _ in $(seq 1 12); do diff --git a/UPSTREAM.md b/UPSTREAM.md index a6e7d384f..c799966f1 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -29,7 +29,7 @@ This repository is a maintained fork of [`PaloAltoNetworks/docusaurus-openapi-do - 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`. If npm succeeds but tag creation fails, rerun with `recover-tag` and `recover theme-vX.Y.Z for @aeei/docusaurus-theme@X.Y.Z`; recovery creates a tag only when a clean rebuild exactly matches npm `dist.integrity`. +- A new publish requires `publish @aeei/docusaurus-theme@X.Y.Z`. If npm succeeds but tag creation fails, rerun with `recover-tag`, the original 40-character publish commit as `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, publishes only that file, verifies registry integrity, then creates `theme-vX.Y.Z` through the GitHub API. ## Baseline diff --git a/examples/docs-starter/config-contract.test.ts b/examples/docs-starter/config-contract.test.ts index fdcba1163..1d23ccb88 100644 --- a/examples/docs-starter/config-contract.test.ts +++ b/examples/docs-starter/config-contract.test.ts @@ -63,6 +63,11 @@ it("publishes only one immutable AEEI theme artifact after explicit approval", ( 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("git merge-base --is-ancestor"); + expect(themeReleaseWorkflow).toContain( + "TARGET_SHA: ${{ needs.prepare.outputs.source_sha }}" + ); expect(themeReleaseWorkflow).toContain( 'expected="publish @aeei/docusaurus-theme@${VERSION}"' ); @@ -72,7 +77,12 @@ it("publishes only one immutable AEEI theme artifact after explicit approval", ( expect(prepareJob).toContain("contents: read"); expect(prepareJob).toContain("persist-credentials: false"); expect(prepareJob).not.toContain("id-token: write"); - expect(prepareJob).toContain("npm pack --silent"); + 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"); From 1d7d7d93ee0c05be3ac403b98beaae5ce00f8ea2 Mon Sep 17 00:00:00 2001 From: aeei <18022843+aeei@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:01:25 +0900 Subject: [PATCH 4/4] fix(release): reserve source tag before publish --- .github/workflows/theme-release.yml | 60 +++++++++++-------- UPSTREAM.md | 5 +- examples/docs-starter/config-contract.test.ts | 10 ++++ 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/.github/workflows/theme-release.yml b/.github/workflows/theme-release.yml index be9c3a544..ec071e5fc 100644 --- a/.github/workflows/theme-release.yml +++ b/.github/workflows/theme-release.yml @@ -20,7 +20,7 @@ on: required: true type: string source_sha: - description: Original 40-character publish commit; required only for recover-tag + description: Original 40-character source commit for retry or recover-tag required: false type: string @@ -48,7 +48,7 @@ jobs: with: persist-credentials: false fetch-depth: 0 - ref: ${{ inputs.operation == 'recover-tag' && inputs.source_sha || github.sha }} + ref: ${{ inputs.source_sha || github.sha }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24.18.0 @@ -68,8 +68,11 @@ jobs: case "$OPERATION" in publish) expected="publish @aeei/docusaurus-theme@${VERSION}" - source_sha="$GITHUB_SHA" - test -z "$INPUT_SOURCE_SHA" || test "$INPUT_SOURCE_SHA" = "$source_sha" + 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}" @@ -82,9 +85,11 @@ jobs: *) echo "Unsupported operation: $OPERATION" >&2; exit 1 ;; esac test "$CONFIRMATION" = "$expected" - if git ls-remote --exit-code --tags origin "refs/tags/theme-v${VERSION}" >/dev/null 2>&1; then - echo "theme-v${VERSION} already exists" >&2 - exit 1 + 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 @@ -165,19 +170,39 @@ jobs: 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 and create immutable source tag + - name: Verify registry integrity env: - GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.prepare.outputs.version }} INTEGRITY: ${{ needs.prepare.outputs.integrity }} - REPOSITORY: ${{ github.repository }} - TARGET_SHA: ${{ needs.prepare.outputs.source_sha }} run: | registry_integrity="" for _ in $(seq 1 12); do @@ -186,16 +211,3 @@ jobs: sleep 5 done test "$registry_integrity" = "$INTEGRITY" - if gh api "repos/${REPOSITORY}/git/ref/tags/theme-v${VERSION}" >/dev/null 2>&1; then - echo "theme-v${VERSION} already exists" >&2 - exit 1 - fi - tag_sha=$(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_sha" diff --git a/UPSTREAM.md b/UPSTREAM.md index c799966f1..5a2051d51 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -29,8 +29,9 @@ This repository is a maintained fork of [`PaloAltoNetworks/docusaurus-openapi-do - 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`. If npm succeeds but tag creation fails, rerun with `recover-tag`, the original 40-character publish commit as `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, publishes only that file, verifies registry integrity, then creates `theme-vX.Y.Z` through the GitHub API. +- 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 diff --git a/examples/docs-starter/config-contract.test.ts b/examples/docs-starter/config-contract.test.ts index 1d23ccb88..5949a22bd 100644 --- a/examples/docs-starter/config-contract.test.ts +++ b/examples/docs-starter/config-contract.test.ts @@ -64,7 +64,14 @@ it("publishes only one immutable AEEI theme artifact after explicit approval", ( 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 }}" ); @@ -89,6 +96,9 @@ it("publishes only one immutable AEEI theme artifact after explicit approval", ( 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");