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
64 changes: 51 additions & 13 deletions .github/workflows/npm-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand All @@ -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:
Expand All @@ -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()
Expand Down
53 changes: 0 additions & 53 deletions .github/workflows/promote-staging-to-candidate.yml

This file was deleted.

36 changes: 36 additions & 0 deletions .github/workflows/remove-staging-tag.yml
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail when the staging-tag lookup fails

If the npm registry is temporarily unavailable or rejects the request, npm view exits nonzero with empty stdout, but 2>/dev/null || true converts that failure into an empty CURRENT; the workflow then reports that the tag is already absent and exits successfully without removing it. The inspected npm view --help describes this command as “View registry info,” and a forced failed lookup returned exit code 1 with empty stdout, so only a successful lookup with no value should take the no-op path—especially because this one-off workflow is intended to be deleted after a green run.

Useful? React with 👍 / 👎.

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})"
44 changes: 0 additions & 44 deletions .github/workflows/rollback-staging.yml

This file was deleted.

152 changes: 152 additions & 0 deletions scripts/__tests__/npm-package-workflow.test.mjs
Original file line number Diff line number Diff line change
@@ -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 がありません");
});
Loading
Loading