diff --git a/.github/workflows/npm-package.yml b/.github/workflows/npm-package.yml index 38df417..be7987c 100644 --- a/.github/workflows/npm-package.yml +++ b/.github/workflows/npm-package.yml @@ -26,7 +26,24 @@ jobs: # (未検知のまま2週間以上放置)。ユーザー判断により、承認不要より既存シークレットの再利用を # 優先し環境参照を復活。以後@staging publishもbash0816さんの手動承認(Deployment approval)が # 必要になる。 + # + # 2026-08-30 (npm publishフロー見直し): @staging中間タグを廃止し、mainブランチから + # 直接@candidateへpublishする2段階フロー(@candidate→@latest)へ変更。@stagingは + # Web/Server側のrevisionベース運用(頻繁に更新される)とnpm publish(publish後は凍結)の + # 構造的ミスマッチが原因で、0.36.0が@stagingへpublishされたまま@candidateへの昇格が + # 一度も試みられず放置される事故を招いた。判定ロジックはscripts/npm-publish-policy.mjs + # へ抽出しユニットテスト済み(scripts/__tests__/npm-publish-policy.test.mjs)。 + # なお Magi-System の main は GitHub側のブランチ保護機能が有料プラン限定のため + # 未設定(ローカルpre-push hookでの直接push抑止のみ)。詳細: + # magi-system側 .codex-review/npm_publish_flow_redesign_design.md steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + - name: Validate inputs env: RUN_ID: ${{ inputs.build_run_id }} @@ -37,7 +54,8 @@ jobs: - name: Verify build run authenticity # dispatch元(publish-magi-server.yml)はこのstep自体をまだ実行中の場合があるため - # status=completedになるまでbounded retryでポーリングする。 + # status=completedになるまでbounded retryでポーリングする。判定ロジック自体は + # scripts/npm-publish-policy-cli.mjs(verify-run)へ委譲する。 env: GH_TOKEN: ${{ secrets.MAGI_SYSTEM_READONLY_PAT }} RUN_ID: ${{ inputs.build_run_id }} @@ -59,12 +77,14 @@ jobs: EVENT=$(echo "$RUN_JSON" | jq -r '.event') CONCLUSION=$(echo "$RUN_JSON" | jq -r '.conclusion') BRANCH=$(echo "$RUN_JSON" | jq -r '.head_branch') - [ "$NAME" = "Publish Magi Server" ] || { echo "::error::Unexpected workflow name: $NAME"; exit 1; } - [ "$PATH_" = ".github/workflows/publish-magi-server.yml" ] || { echo "::error::Unexpected workflow path: $PATH_"; exit 1; } - [ "$EVENT" = "workflow_dispatch" ] || { echo "::error::Unexpected event: $EVENT"; exit 1; } - [ "$CONCLUSION" = "success" ] || { echo "::error::Run did not succeed: $CONCLUSION"; exit 1; } - [ "$BRANCH" = "staging" ] || { echo "::error::Unexpected branch: $BRANCH"; exit 1; } - echo "Run authenticity verified: $NAME #$RUN_ID (branch=$BRANCH, conclusion=$CONCLUSION)" + HEAD_SHA=$(echo "$RUN_JSON" | jq -r '.head_sha') + MAIN_SHA=$(gh api repos/bash0816/Magi-System/commits/main --jq '.sha') + + node scripts/npm-publish-policy-cli.mjs verify-run \ + --workflow-name="$NAME" --workflow-path="$PATH_" --event="$EVENT" \ + --conclusion="$CONCLUSION" --branch="$BRANCH" \ + --head-sha="$HEAD_SHA" --main-sha="$MAIN_SHA" + echo "Run authenticity verified: $NAME #$RUN_ID (branch=$BRANCH, head_sha=$HEAD_SHA, conclusion=$CONCLUSION)" - name: Download tarball from Magi-System internal release env: @@ -89,17 +109,35 @@ jobs: [ "$ACTUAL" = "$EXPECTED" ] || { echo "::error::sha256 mismatch (expected=$EXPECTED actual=$ACTUAL)"; exit 1; } echo "integrity OK: $ACTUAL" - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "22" - registry-url: "https://registry.npmjs.org" + - name: Verify candidate version is not a downgrade + # 2026-08-30追加(条件2): @staging廃止に伴い、旧promote-staging-to-candidate.ymlに + # あったcandidateダウングレード防止チェックをここへ移植。判定ロジックは + # scripts/npm-publish-policy-cli.mjs(verify-candidate-version)へ委譲する。 + id: version + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -eu + TGZ_FILES=(/tmp/magi-publish/bash0816-magi-server-*.tgz) + VERSION=$(tar -xzO -f "${TGZ_FILES[0]}" package/package.json | node -e " + let d = ''; + process.stdin.on('data', c => d += c); + process.stdin.on('end', () => console.log(JSON.parse(d).version)); + ") + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + CURRENT_CANDIDATE=$(npm view "@bash0816/magi-server" dist-tags.candidate 2>/dev/null || true) + node scripts/npm-publish-policy-cli.mjs verify-candidate-version \ + --version="$VERSION" --current-candidate="${CURRENT_CANDIDATE:-}" - - name: Publish to npm as @staging + - name: Publish to npm as @candidate env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | TGZ_FILES=(/tmp/magi-publish/bash0816-magi-server-*.tgz) - npm publish "${TGZ_FILES[0]}" --ignore-scripts --access public --tag staging + # 2026-08-30変更(条件1): @staging → @candidate へpublish先を変更 + npm publish "${TGZ_FILES[0]}" --ignore-scripts --access public --tag candidate + echo "Published @bash0816/magi-server@${{ steps.version.outputs.version }} to @candidate" - name: Cleanup if: always() diff --git a/.github/workflows/promote-staging-to-candidate.yml b/.github/workflows/promote-staging-to-candidate.yml deleted file mode 100644 index da9e91c..0000000 --- a/.github/workflows/promote-staging-to-candidate.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Promote magi-server staging to candidate - -on: - workflow_dispatch: - inputs: - version: - description: "Version to promote from @staging to @candidate (e.g. 0.34.0)" - required: true - type: string - -concurrency: - group: npm-publish-magi-server - cancel-in-progress: false - -jobs: - promote: - runs-on: ubuntu-latest - environment: npm-publish - steps: - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "22" - registry-url: "https://registry.npmjs.org" - - - name: Validate and promote @staging to @candidate - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - VERSION: ${{ inputs.version }} - run: | - echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ - || { echo "::error::Invalid semver: $VERSION"; exit 1; } - - STAGING=$(npm view "@bash0816/magi-server" dist-tags.staging 2>/dev/null || true) - [ "$STAGING" = "$VERSION" ] \ - || { echo "::error::@staging is '$STAGING', not '$VERSION'. Publish to @staging first."; exit 1; } - - CURRENT_CANDIDATE=$(npm view "@bash0816/magi-server" dist-tags.candidate 2>/dev/null || true) - if [ -n "$CURRENT_CANDIDATE" ]; then - COMPARE=$(node -e " - const a = '$VERSION'.split('.').map(Number); - const b = '$CURRENT_CANDIDATE'.split('.').map(Number); - for (let i = 0; i < 3; i++) { - if (a[i] > b[i]) { process.stdout.write('gt'); process.exit(); } - if (a[i] < b[i]) { process.stdout.write('lt'); process.exit(); } - } - process.stdout.write('eq'); - ") - [ "$COMPARE" != "lt" ] \ - || { echo "::error::Would downgrade @candidate from $CURRENT_CANDIDATE to $VERSION"; exit 1; } - fi - - npm dist-tag add "@bash0816/magi-server@${VERSION}" candidate - echo "Promoted @bash0816/magi-server@${VERSION}: @staging → @candidate" diff --git a/.github/workflows/remove-staging-tag.yml b/.github/workflows/remove-staging-tag.yml new file mode 100644 index 0000000..883f9f3 --- /dev/null +++ b/.github/workflows/remove-staging-tag.yml @@ -0,0 +1,36 @@ +name: Remove staging dist-tag (one-off) + +# 2026-08-30 (npm publishフロー見直し): @staging中間タグ廃止に伴うワンオフの後始末。 +# @stagingは0.36.0を指したまま凍結されており、terra指摘の通り「タグから参照されない」 +# 状態にするには明示的な削除が必要。実行完了後、このワークフローファイル自体を別PRで削除する。 +# 詳細: magi-system側 .codex-review/npm_publish_flow_redesign_design.md + +on: + workflow_dispatch: + +concurrency: + group: npm-publish-magi-server + cancel-in-progress: false + +jobs: + remove-tag: + runs-on: ubuntu-latest + environment: npm-publish + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: Remove @staging dist-tag + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -eu + CURRENT=$(npm view "@bash0816/magi-server" dist-tags.staging 2>/dev/null || true) + if [ -z "$CURRENT" ]; then + echo "@staging tag already absent, nothing to do" + exit 0 + fi + npm dist-tag rm @bash0816/magi-server staging + echo "Removed @staging tag (was pointing to ${CURRENT})" diff --git a/.github/workflows/rollback-staging.yml b/.github/workflows/rollback-staging.yml deleted file mode 100644 index f263165..0000000 --- a/.github/workflows/rollback-staging.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Rollback magi-server Staging - -on: - workflow_dispatch: - inputs: - version: - description: "Version to rollback @staging to (e.g. 0.35.2)" - required: true - type: string - reason: - description: "Reason for rollback (audit log)" - required: true - type: string - -concurrency: - group: npm-publish-magi-server - cancel-in-progress: false - -jobs: - rollback: - runs-on: ubuntu-latest - environment: npm-publish - steps: - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "22" - registry-url: "https://registry.npmjs.org" - - - name: Rollback @staging - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - VERSION: ${{ inputs.version }} - REASON: ${{ inputs.reason }} - run: | - echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ - || { echo "::error::Invalid semver: $VERSION"; exit 1; } - - FOUND=$(npm view "@bash0816/magi-server@${VERSION}" version 2>/dev/null || true) - [ "$FOUND" = "$VERSION" ] \ - || { echo "::error::$VERSION not found on npm registry"; exit 1; } - - echo "ROLLBACK: reason=$REASON target=$VERSION" - npm dist-tag add "@bash0816/magi-server@${VERSION}" staging - echo "Rolled back @bash0816/magi-server @staging → $VERSION" diff --git a/scripts/__tests__/npm-package-workflow.test.mjs b/scripts/__tests__/npm-package-workflow.test.mjs new file mode 100644 index 0000000..d46b397 --- /dev/null +++ b/scripts/__tests__/npm-package-workflow.test.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +// npm-package.yml が npm-publish-policy-cli.mjs を実際に呼んでいることを検証する +// 契約テスト。ロジック本体(scripts/npm-publish-policy.mjs)はユニットテスト済みだが、 +// YAML側が古いインラインロジックへ差し戻された・呼び出しが欠落した、といった +// 配線ミスはYAMLを直接読まないと検出できないため別途用意する(terra指摘、条件7)。 + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const WORKFLOW_FILE = path.join(ROOT, ".github/workflows/npm-package.yml"); + +test("checkout ステップが存在する(scripts/を読むために必須)", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + assert.match(content, /actions\/checkout@/); +}); + +test("Verify build run authenticity ステップが npm-publish-policy-cli.mjs verify-run を呼んでいる", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + const stepPattern = /- name: Verify build run authenticity[\s\S]*?(?=\n\s*- name:|\n\s*$)/; + const stepMatch = content.match(stepPattern); + assert(stepMatch, "Verify build run authenticity ステップが見つかりません"); + assert.match( + stepMatch[0], + /node scripts\/npm-publish-policy-cli\.mjs verify-run/, + "verify-run コマンドの呼び出しが見つかりません(インラインロジックへ差し戻された可能性)", + ); + // 条件1: staging を許可する記述(古い契約)が復活していないことも確認 + assert.doesNotMatch( + stepMatch[0], + /\[\s*"\$BRANCH"\s*=\s*"staging"\s*\]/, + "staging ブランチを許可する古い条件が残っています", + ); +}); + +test("Verify candidate version is not a downgrade ステップが npm-publish-policy-cli.mjs verify-candidate-version を呼んでいる", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + const stepPattern = /- name: Verify candidate version is not a downgrade[\s\S]*?(?=\n\s*- name:|\n\s*$)/; + const stepMatch = content.match(stepPattern); + assert(stepMatch, "Verify candidate version is not a downgrade ステップが見つかりません"); + assert.match( + stepMatch[0], + /node scripts\/npm-publish-policy-cli\.mjs verify-candidate-version/, + "verify-candidate-version コマンドの呼び出しが見つかりません", + ); +}); + +test("Publish to npm ステップが --tag candidate を指定している(--tag staging ではない)", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + const stepPattern = /- name: Publish to npm[\s\S]*?(?=\n\s*- name:|\n\s*$)/; + const stepMatch = content.match(stepPattern); + assert(stepMatch, "Publish to npm ステップが見つかりません"); + assert.match(stepMatch[0], /--tag candidate/, "--tag candidate の指定が見つかりません"); + assert.doesNotMatch(stepMatch[0], /--tag staging/, "--tag staging が残っています"); +}); + +test("concurrency グループが維持されている", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + assert.match(content, /group:\s*npm-publish-magi-server/); + assert.match(content, /cancel-in-progress:\s*false/); +}); + +test("npm-publish environment が維持されている(承認ゲート)", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + assert.match(content, /environment:\s*npm-publish/); +}); + +/** ステップ名一覧を出現順で抽出する(YAML簡易パース、model-new-watch-workflow.test.mjsと同型) */ +function extractStepOrder(content) { + const pattern = /^\s*-\s+(?:name:\s*(.+?)|uses:\s*(.+?))\s*$/gm; + const steps = []; + let match; + while ((match = pattern.exec(content)) !== null) { + steps.push(match[1] ?? `uses:${match[2]}`); + } + return steps; +} + +test("STEP8指摘1: checkout/setup-node が Verify build run authenticity より前にある(scripts/実行に必須)", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + const order = extractStepOrder(content); + const checkoutIdx = order.findIndex((s) => s.startsWith("uses:actions/checkout")); + const setupNodeIdx = order.findIndex((s) => s.startsWith("uses:actions/setup-node")); + const verifyRunIdx = order.indexOf("Verify build run authenticity"); + assert.notEqual(checkoutIdx, -1, "checkoutステップが見つかりません"); + assert.notEqual(setupNodeIdx, -1, "setup-nodeステップが見つかりません"); + assert.notEqual(verifyRunIdx, -1, "Verify build run authenticityステップが見つかりません"); + assert(checkoutIdx < verifyRunIdx, "checkoutがVerify build run authenticityより後にあります"); + assert(setupNodeIdx < verifyRunIdx, "setup-nodeがVerify build run authenticityより後にあります"); +}); + +test("STEP8指摘1: checkout/setup-node が Verify candidate version より前にある", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + const order = extractStepOrder(content); + const checkoutIdx = order.findIndex((s) => s.startsWith("uses:actions/checkout")); + const setupNodeIdx = order.findIndex((s) => s.startsWith("uses:actions/setup-node")); + const verifyCandidateIdx = order.indexOf("Verify candidate version is not a downgrade"); + assert.notEqual(verifyCandidateIdx, -1, "Verify candidate versionステップが見つかりません"); + assert(checkoutIdx < verifyCandidateIdx); + assert(setupNodeIdx < verifyCandidateIdx); +}); + +test("STEP8指摘4: Publish to npm as @candidate が Verify candidate version より後にある(検証をすり抜けてpublishできない)", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + const order = extractStepOrder(content); + const verifyCandidateIdx = order.indexOf("Verify candidate version is not a downgrade"); + const publishIdx = order.indexOf("Publish to npm as @candidate"); + assert.notEqual(publishIdx, -1, "Publish to npm as @candidateステップが見つかりません"); + assert(verifyCandidateIdx < publishIdx, "publishがcandidateダウングレード検証より前で実行され得ます"); +}); + +test("STEP8指摘2: verify-run 呼び出しに --head-sha と --main-sha が渡っている", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + const stepPattern = /- name: Verify build run authenticity[\s\S]*?(?=\n\s*- name:|\n\s*$)/; + const stepMatch = content.match(stepPattern); + assert(stepMatch, "Verify build run authenticity ステップが見つかりません"); + const callPattern = /node scripts\/npm-publish-policy-cli\.mjs verify-run[\s\S]*?(?=\n\s*echo|\n\s*$)/; + const callMatch = stepMatch[0].match(callPattern); + assert(callMatch, "verify-run 呼び出し全体が見つかりません"); + assert.match(callMatch[0], /--head-sha="\$HEAD_SHA"/, "--head-sha が渡されていません"); + assert.match(callMatch[0], /--main-sha="\$MAIN_SHA"/, "--main-sha が渡されていません"); + // head_shaがMagi-Systemのmain先端から取得されていることも確認(なりすまし防止) + assert.match( + stepMatch[0], + /MAIN_SHA=\$\(gh api repos\/bash0816\/Magi-System\/commits\/main --jq '\.sha'\)/, + "MAIN_SHAがMagi-Systemのmain先端から取得されていません", + ); +}); + +test("STEP8指摘3: policy CLI呼び出しが失敗を握りつぶしていない(|| true 等が付いていない)", () => { + const content = readFileSync(WORKFLOW_FILE, "utf8"); + const callSites = [ + ...content.matchAll(/node scripts\/npm-publish-policy-cli\.mjs[^\n]*(?:\\\n\s*[^\n]*)*/g), + ]; + assert(callSites.length >= 2, `policy CLI呼び出しが2箇所(verify-run, verify-candidate-version)未満: ${callSites.length}`); + for (const call of callSites) { + assert.doesNotMatch( + call[0], + /\|\|\s*true\s*$/m, + `policy CLI呼び出しが失敗を握りつぶしています: ${call[0]}`, + ); + } + // set -eu が各該当stepで有効であること(コマンド自体の失敗がstep失敗に伝播する前提) + const verifyRunStep = content.match(/- name: Verify build run authenticity[\s\S]*?(?=\n\s*- name:|\n\s*$)/)[0]; + const verifyCandidateStep = content.match( + /- name: Verify candidate version is not a downgrade[\s\S]*?(?=\n\s*- name:|\n\s*$)/, + )[0]; + assert.match(verifyRunStep, /set -eu/, "Verify build run authenticity に set -eu がありません"); + assert.match(verifyCandidateStep, /set -eu/, "Verify candidate version is not a downgrade に set -eu がありません"); +}); diff --git a/scripts/__tests__/npm-publish-policy.test.mjs b/scripts/__tests__/npm-publish-policy.test.mjs new file mode 100644 index 0000000..4d8ea50 --- /dev/null +++ b/scripts/__tests__/npm-publish-policy.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + verifyRunMetadata, + verifyCandidateVersion, + isStrictSemver, + compareSemver, +} from "../npm-publish-policy.mjs"; + +const VALID_RUN = { + workflowName: "Publish Magi Server", + workflowPath: ".github/workflows/publish-magi-server.yml", + event: "workflow_dispatch", + conclusion: "success", + branch: "main", + headSha: "abc123", + mainSha: "abc123", +}; + +describe("verifyRunMetadata", () => { + it("main かつ SHA一致なら許可する", () => { + const result = verifyRunMetadata(VALID_RUN); + assert.equal(result.ok, true); + assert.deepEqual(result.errors, []); + }); + + it("staging ブランチは拒否する", () => { + const result = verifyRunMetadata({ ...VALID_RUN, branch: "staging" }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /Unexpected branch: staging/); + }); + + it("head_sha が main SHA と不一致なら拒否する(stale run再利用防止)", () => { + const result = verifyRunMetadata({ ...VALID_RUN, headSha: "old-sha", mainSha: "new-sha" }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /does not match current Magi-System main/); + }); + + it("workflow名が不一致なら拒否する(なりすまし防止)", () => { + const result = verifyRunMetadata({ ...VALID_RUN, workflowName: "Evil Workflow" }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /Unexpected workflow name/); + }); + + it("workflowPathが不一致なら拒否する", () => { + const result = verifyRunMetadata({ ...VALID_RUN, workflowPath: ".github/workflows/evil.yml" }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /Unexpected workflow path/); + }); + + it("eventがworkflow_dispatch以外なら拒否する", () => { + const result = verifyRunMetadata({ ...VALID_RUN, event: "push" }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /Unexpected event/); + }); + + it("conclusionがsuccess以外なら拒否する", () => { + const result = verifyRunMetadata({ ...VALID_RUN, conclusion: "failure" }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /Run did not succeed/); + }); + + it("複数条件が同時に不一致な場合は全エラーを列挙する", () => { + const result = verifyRunMetadata({ ...VALID_RUN, branch: "staging", headSha: "x", mainSha: "y" }); + assert.equal(result.ok, false); + assert.equal(result.errors.length, 2); + }); +}); + +describe("isStrictSemver", () => { + it("X.Y.Z形式は真", () => { + assert.equal(isStrictSemver("0.36.1"), true); + assert.equal(isStrictSemver("1.0.0"), true); + }); + + it("prerelease(0.36.0-beta.1)は偽", () => { + assert.equal(isStrictSemver("0.36.0-beta.1"), false); + }); + + it("不正な形式は偽", () => { + assert.equal(isStrictSemver("v1.0.0"), false); + assert.equal(isStrictSemver("1.0"), false); + assert.equal(isStrictSemver(""), false); + assert.equal(isStrictSemver(undefined), false); + }); +}); + +describe("compareSemver", () => { + it("大小関係を正しく判定する", () => { + assert.equal(compareSemver("0.36.1", "0.36.0"), "gt"); + assert.equal(compareSemver("0.36.0", "0.36.1"), "lt"); + assert.equal(compareSemver("0.36.1", "0.36.1"), "eq"); + assert.equal(compareSemver("1.0.0", "0.99.99"), "gt"); + }); +}); + +describe("verifyCandidateVersion", () => { + it("既存candidateがない場合は正式版なら常に許可する", () => { + const result = verifyCandidateVersion({ version: "0.36.1", currentCandidate: undefined }); + assert.equal(result.ok, true); + }); + + it("既存candidateより新しいバージョンは許可する", () => { + const result = verifyCandidateVersion({ version: "0.36.1", currentCandidate: "0.35.2" }); + assert.equal(result.ok, true); + }); + + it("既存candidateと同一バージョンは拒否する(npm publishは同一versionを再publishできないため、STEP8指摘)", () => { + const result = verifyCandidateVersion({ version: "0.36.1", currentCandidate: "0.36.1" }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /already published as @candidate/); + }); + + it("既存candidateより古いバージョンは拒否する(ダウングレード防止)", () => { + const result = verifyCandidateVersion({ version: "0.35.2", currentCandidate: "0.36.1" }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /Would downgrade @candidate/); + }); + + it("prerelease版はcandidateより新しくても拒否する(条件2: terra指摘の再発防止)", () => { + // 0.36.0-beta.1 は Number()比較だとNaNになり、旧実装ではダウングレード判定を + // すり抜けて誤って古いcandidateを通してしまう可能性があった。 + const result = verifyCandidateVersion({ version: "0.36.0-beta.1", currentCandidate: "0.35.2" }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /Invalid semver \(prerelease not supported\)/); + }); + + it("既存candidate自体が不正なsemverなら安全側に拒否する", () => { + const result = verifyCandidateVersion({ version: "0.36.1", currentCandidate: "not-a-version" }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /Invalid semver for current @candidate tag/); + }); +}); diff --git a/scripts/npm-publish-policy-cli.mjs b/scripts/npm-publish-policy-cli.mjs new file mode 100644 index 0000000..ad7d4b8 --- /dev/null +++ b/scripts/npm-publish-policy-cli.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// npm-publish-policy.mjs の薄いCLIラッパー。GitHub Actions(npm-package.yml)から +// `node scripts/npm-publish-policy-cli.mjs --key=value ...`の形式で呼ばれる。 +// 判定結果を::error::行として出力しexit codeで成否を返す。ロジック本体は +// npm-publish-policy.mjs 側でユニットテスト済み、ここでは配線のみ担う。 + +import { verifyRunMetadata, verifyCandidateVersion } from "./npm-publish-policy.mjs"; + +function parseArgs(argv) { + const opts = {}; + for (const arg of argv) { + const m = arg.match(/^--([^=]+)=([\s\S]*)$/); + if (m) opts[m[1]] = m[2]; + } + return opts; +} + +function reportAndExit(result) { + if (!result.ok) { + for (const e of result.errors) { + console.error(`::error::${e}`); + } + process.exit(1); + } +} + +const [, , command, ...rest] = process.argv; +const opts = parseArgs(rest); + +if (command === "verify-run") { + const result = verifyRunMetadata({ + workflowName: opts["workflow-name"], + workflowPath: opts["workflow-path"], + event: opts["event"], + conclusion: opts["conclusion"], + branch: opts["branch"], + headSha: opts["head-sha"], + mainSha: opts["main-sha"], + }); + reportAndExit(result); + console.log( + `Run authenticity verified: ${opts["workflow-name"]} (branch=${opts["branch"]}, head_sha=${opts["head-sha"]})`, + ); +} else if (command === "verify-candidate-version") { + const result = verifyCandidateVersion({ + version: opts["version"], + currentCandidate: opts["current-candidate"] || undefined, + }); + reportAndExit(result); + console.log(`candidate version check OK: ${opts["version"]} (current: ${opts["current-candidate"] || "none"})`); +} else { + console.error(`::error::Unknown command: ${command}. Expected: verify-run | verify-candidate-version`); + process.exit(1); +} diff --git a/scripts/npm-publish-policy.mjs b/scripts/npm-publish-policy.mjs new file mode 100644 index 0000000..7c38dcb --- /dev/null +++ b/scripts/npm-publish-policy.mjs @@ -0,0 +1,81 @@ +// npm publishフロー(@candidate→@latest)の判定ロジック。 +// GitHub Actions YAML(npm-package.yml)から呼び出される薄いCLIラッパー +// (npm-publish-policy-cli.mjs)の裏側。副作用なし・純粋関数のみでテスト可能にする。 +// 詳細: magi-system側 .codex-review/npm_publish_flow_redesign_design.md + +const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +/** + * dispatch元run(Magi-System側 publish-magi-server.yml)のmetadataを検証する。 + * @staging中間タグを廃止し main ビルドのみを受け付ける(条件1)。 + * head_sha が Magi-System の main 先端と一致することも要求する(条件3, stale run再利用防止)。 + */ +export function verifyRunMetadata({ workflowName, workflowPath, event, conclusion, branch, headSha, mainSha }) { + const errors = []; + if (workflowName !== "Publish Magi Server") { + errors.push(`Unexpected workflow name: ${workflowName}`); + } + if (workflowPath !== ".github/workflows/publish-magi-server.yml") { + errors.push(`Unexpected workflow path: ${workflowPath}`); + } + if (event !== "workflow_dispatch") { + errors.push(`Unexpected event: ${event}`); + } + if (conclusion !== "success") { + errors.push(`Run did not succeed: ${conclusion}`); + } + if (branch !== "main") { + errors.push(`Unexpected branch: ${branch} (expected: main)`); + } + if (headSha !== mainSha) { + errors.push( + `head_sha (${headSha}) does not match current Magi-System main (${mainSha}). ` + + `The build run is stale (main has advanced since this build). Re-run the build workflow.`, + ); + } + return { ok: errors.length === 0, errors }; +} + +/** 厳密な正式版セマンティックバージョン(X.Y.Z、prereleaseは非対応)かを判定する。 */ +export function isStrictSemver(version) { + return typeof version === "string" && SEMVER_RE.test(version); +} + +/** X.Y.Z形式の2つのバージョンを比較する。isStrictSemverで検証済みの入力を前提とする。 */ +export function compareSemver(a, b) { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (pa[i] > pb[i]) return "gt"; + if (pa[i] < pb[i]) return "lt"; + } + return "eq"; +} + +/** + * publish対象versionが@candidateへのダウングレードでないことを検証する。 + * 条件2: prereleaseは許容しない(正式版X.Y.Zのみ)。旧promote-staging-to-candidate.ymlの + * ダウングレード防止チェックの移植 + semver形式検証の追加。 + */ +export function verifyCandidateVersion({ version, currentCandidate }) { + const errors = []; + if (!isStrictSemver(version)) { + errors.push(`Invalid semver (prerelease not supported): ${version}`); + return { ok: false, errors }; + } + if (currentCandidate) { + if (!isStrictSemver(currentCandidate)) { + errors.push(`Invalid semver for current @candidate tag: ${currentCandidate}`); + return { ok: false, errors }; + } + const cmp = compareSemver(version, currentCandidate); + if (cmp === "lt") { + errors.push(`Would downgrade @candidate from ${currentCandidate} to ${version}`); + } else if (cmp === "eq") { + // STEP8レビュー指摘: npm publishは同一バージョンを再publishできず失敗する。 + // 「冪等に許可」は実運用と不整合なため明示的に拒否し、PATCHバンプを促す。 + errors.push(`Version ${version} is already published as @candidate. Bump the version (PATCH or higher) before publishing again.`); + } + } + return { ok: errors.length === 0, errors }; +}