diff --git a/.github/workflows/auto-stage-candidate.yml b/.github/workflows/auto-stage-candidate.yml index 0fe9373..316a3cc 100644 --- a/.github/workflows/auto-stage-candidate.yml +++ b/.github/workflows/auto-stage-candidate.yml @@ -135,35 +135,50 @@ jobs: BRANCH: ${{ steps.check_state.outputs.branch }} run: | set -eu + + # Blocker A 対策(v8): checkout直前に再fetchし、その時点のSHAを + # 記録しておく。push は force-with-lease でこのSHAに固定し、 + # (a) 通常の並行更新、(b) codex-version-watch.yml が同名ブランチに + # 対して行う --force-with-lease 更新、の両方を確実に検出する。 + git fetch origin "$BRANCH:refs/remotes/origin/$BRANCH" + expected_sha=$(git rev-parse "origin/$BRANCH") git checkout -B "$BRANCH" "origin/$BRANCH" + recheck_ver=$(node -p "JSON.parse(require('fs').readFileSync('config/codex-termux-release-manifest.json','utf8')).latest_candidate_version") + recheck_state=$(node -p "JSON.parse(require('fs').readFileSync('config/codex-termux-release-manifest.json','utf8')).candidate_state_status") + recheck_run_id=$(node -p "JSON.parse(require('fs').readFileSync('config/codex-termux-release-manifest.json','utf8')).expected_build_run_id || ''") + recheck_pkg_ver=$(node -p "JSON.parse(require('fs').readFileSync('packages/codex-termux/config/codex-termux-release-manifest.json','utf8')).latest_candidate_version") + recheck_pkg_state=$(node -p "JSON.parse(require('fs').readFileSync('packages/codex-termux/config/codex-termux-release-manifest.json','utf8')).candidate_state_status") + if [ "$recheck_ver" != "$VER" ] || [ "$recheck_state" != "codex_build_dispatched" ] || [ "$recheck_run_id" != "$BUILD_RUN_ID" ] || [ "$recheck_pkg_ver" != "$VER" ] || [ "$recheck_pkg_state" != "codex_build_dispatched" ]; then + echo "ERROR: candidate branch state changed between check and update (ver=$recheck_ver state=$recheck_state expected_run_id=$recheck_run_id pkg_ver=$recheck_pkg_ver pkg_state=$recheck_pkg_state)" >&2 + exit 1 + fi + + # candidate branch は main からフォークされた時点のスクリプトの + # スナップショットしか持たない。stage サブコマンドが確実に + # 使えるよう main 版で上書きし、そのままコミット対象に含める。 + git checkout origin/main -- scripts/update-codex-candidate-manifest.js + + node scripts/update-codex-candidate-manifest.js stage "$BUILD_RUN_ID" "$SOURCE_REF" "$SOURCE_SHA" + node - <<'NODE' const fs = require('fs'); - const manifestPath = 'config/codex-termux-release-manifest.json'; - const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - m.build_run_id = String(process.env.BUILD_RUN_ID); - m.source_ref = process.env.SOURCE_REF; - m.source_sha = process.env.SOURCE_SHA; - m.candidate_state_status = 'ready_to_publish'; - m.canonical_package_status = 'ready_to_publish'; - m.public_distribution_status = 'staged_for_publish'; - m.updated_at = new Date().toISOString(); - fs.writeFileSync(manifestPath, JSON.stringify(m, null, 2) + '\n'); - const pkgPath = 'packages/codex-termux/package.json'; const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); pkg.version = process.env.VER; fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); - - console.log('manifest updated: candidate ready_to_publish, package.json version set to', process.env.VER); + console.log('package.json version set to', process.env.VER); NODE git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add config/codex-termux-release-manifest.json packages/codex-termux/package.json + git add config/codex-termux-release-manifest.json \ + packages/codex-termux/config/codex-termux-release-manifest.json \ + packages/codex-termux/package.json \ + scripts/update-codex-candidate-manifest.js git diff --cached --quiet && echo "Nothing to commit" && exit 0 git commit -m "stage: codex ${VER} ready_to_publish (build run ${BUILD_RUN_ID})" - git push origin "HEAD:$BRANCH" + git push --force-with-lease="refs/heads/${BRANCH}:${expected_sha}" origin "HEAD:$BRANCH" echo "Pushed ready_to_publish state to $BRANCH" - name: Comment on candidate PR diff --git a/.github/workflows/npm-package.yml b/.github/workflows/npm-package.yml index 6fae26f..2c3b94a 100644 --- a/.github/workflows/npm-package.yml +++ b/.github/workflows/npm-package.yml @@ -68,6 +68,8 @@ jobs: node --check ../../scripts/sync-public-release-from-manifest.js node --check ../../scripts/check-public-release-skeleton.js node --check ../../scripts/check-public-publish-guard.js + node --check ../../scripts/update-codex-candidate-manifest.js + node --test ../../scripts/update-codex-candidate-manifest.test.js node --test ../../scripts/sync-public-release-from-manifest.test.js node --test bin/magi-node-launcher.test.js diff --git a/scripts/update-codex-candidate-manifest.js b/scripts/update-codex-candidate-manifest.js index 2d429ce..bc788d7 100644 --- a/scripts/update-codex-candidate-manifest.js +++ b/scripts/update-codex-candidate-manifest.js @@ -40,6 +40,50 @@ function updateManifests({ fs.writeFileSync(packagePath, `${JSON.stringify(packageManifest, null, 2)}\n`); } +/** + * Stage both root and package manifests to ready_to_publish after a + * successful build. Root manifest additionally records build provenance + * (build_run_id/source_ref/source_sha); package manifest schema does not + * carry those fields, so it is intentionally excluded from that part. + * + * @param {Object} opts + * @param {string} opts.rootPath - path to root manifest + * @param {string} opts.packagePath - path to package manifest + * @param {string} opts.buildRunId - build_run_id to record on root manifest + * @param {string} opts.sourceRef - source_ref to record on root manifest + * @param {string} opts.sourceSha - source_sha to record on root manifest + */ +function stageManifestsReadyToPublish({ + rootPath, + packagePath, + buildRunId, + sourceRef, + sourceSha, +}) { + if (!rootPath || !packagePath || !buildRunId || !sourceRef || !sourceSha) { + throw new Error('All parameters (rootPath, packagePath, buildRunId, sourceRef, sourceSha) are required'); + } + + const updatedAt = new Date().toISOString(); + + const rootManifest = JSON.parse(fs.readFileSync(rootPath, 'utf8')); + rootManifest.build_run_id = String(buildRunId); + rootManifest.source_ref = sourceRef; + rootManifest.source_sha = sourceSha; + rootManifest.candidate_state_status = 'ready_to_publish'; + rootManifest.canonical_package_status = 'ready_to_publish'; + rootManifest.public_distribution_status = 'staged_for_publish'; + rootManifest.updated_at = updatedAt; + fs.writeFileSync(rootPath, `${JSON.stringify(rootManifest, null, 2)}\n`); + + const packageManifest = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + packageManifest.candidate_state_status = 'ready_to_publish'; + packageManifest.canonical_package_status = 'ready_to_publish'; + packageManifest.public_distribution_status = 'staged_for_publish'; + packageManifest.updated_at = updatedAt; + fs.writeFileSync(packagePath, `${JSON.stringify(packageManifest, null, 2)}\n`); +} + // CLI interface if (require.main === module) { const repoRoot = path.resolve(__dirname, '..'); @@ -54,27 +98,49 @@ if (require.main === module) { function usage() { console.error('usage: node scripts/update-codex-candidate-manifest.js '); + console.error(' node scripts/update-codex-candidate-manifest.js stage '); process.exit(2); } - const candidateVersion = process.argv[2]; - const candidateState = process.argv[3]; - - if (!candidateVersion || !candidateState) { - usage(); - } + const firstArg = process.argv[2]; - try { - updateManifests({ - rootPath: rootManifestPath, - packagePath: packageManifestPath, - candidateVersion, - candidateState, - }); - } catch (err) { - console.error('Error updating manifests:', err.message); - process.exit(1); + if (firstArg === 'stage') { + if (process.argv.length !== 6) { + usage(); + } + const buildRunId = process.argv[3]; + const sourceRef = process.argv[4]; + const sourceSha = process.argv[5]; + try { + stageManifestsReadyToPublish({ + rootPath: rootManifestPath, + packagePath: packageManifestPath, + buildRunId, + sourceRef, + sourceSha, + }); + } catch (err) { + console.error('Error staging manifests:', err.message); + process.exit(1); + } + } else { + if (process.argv.length !== 4) { + usage(); + } + const candidateVersion = process.argv[2]; + const candidateState = process.argv[3]; + try { + updateManifests({ + rootPath: rootManifestPath, + packagePath: packageManifestPath, + candidateVersion, + candidateState, + }); + } catch (err) { + console.error('Error updating manifests:', err.message); + process.exit(1); + } } } -module.exports = { updateManifests }; +module.exports = { updateManifests, stageManifestsReadyToPublish }; diff --git a/scripts/update-codex-candidate-manifest.test.js b/scripts/update-codex-candidate-manifest.test.js index bcbc3cf..b8cfb8e 100644 --- a/scripts/update-codex-candidate-manifest.test.js +++ b/scripts/update-codex-candidate-manifest.test.js @@ -2,7 +2,8 @@ const { test } = require('node:test'); const { strict: assert } = require('assert'); const fs = require('fs'); const path = require('path'); -const { updateManifests } = require('./update-codex-candidate-manifest.js'); +const { spawnSync } = require('child_process'); +const { updateManifests, stageManifestsReadyToPublish } = require('./update-codex-candidate-manifest.js'); // Test helper to create a manifest with defaults function createManifest(overrides = {}) { @@ -256,3 +257,161 @@ test('updateManifests: preserves package-specific update_guidance field', () => fs.rmSync(tmpDir, { recursive: true }); } }); + +test('stageManifestsReadyToPublish: updates state fields in both root and package manifests', () => { + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'test-')); + try { + const rootPath = path.join(tmpDir, 'root-manifest.json'); + const packagePath = path.join(tmpDir, 'package-manifest.json'); + + fs.writeFileSync(rootPath, JSON.stringify(createManifest({ + candidate_state_status: 'codex_build_dispatched', + canonical_package_status: 'published', + public_distribution_status: 'published', + }), null, 2) + '\n'); + fs.writeFileSync(packagePath, JSON.stringify(createPackageManifest({ + candidate_state_status: 'codex_build_dispatched', + canonical_package_status: 'published', + public_distribution_status: 'published', + }), null, 2) + '\n'); + + stageManifestsReadyToPublish({ + rootPath, + packagePath, + buildRunId: '12345', + sourceRef: 'rust-v0.145.0', + sourceSha: 'deadbeefcafe', + }); + + const rootManifest = JSON.parse(fs.readFileSync(rootPath, 'utf8')); + const packageManifest = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + + assert.equal(rootManifest.candidate_state_status, 'ready_to_publish'); + assert.equal(rootManifest.canonical_package_status, 'ready_to_publish'); + assert.equal(rootManifest.public_distribution_status, 'staged_for_publish'); + assert.equal(packageManifest.candidate_state_status, 'ready_to_publish'); + assert.equal(packageManifest.canonical_package_status, 'ready_to_publish'); + assert.equal(packageManifest.public_distribution_status, 'staged_for_publish'); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test('stageManifestsReadyToPublish: updated_at is identical in both root and package manifests', () => { + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'test-')); + try { + const rootPath = path.join(tmpDir, 'root-manifest.json'); + const packagePath = path.join(tmpDir, 'package-manifest.json'); + + fs.writeFileSync(rootPath, JSON.stringify(createManifest(), null, 2) + '\n'); + fs.writeFileSync(packagePath, JSON.stringify(createPackageManifest(), null, 2) + '\n'); + + stageManifestsReadyToPublish({ + rootPath, + packagePath, + buildRunId: '12345', + sourceRef: 'rust-v0.145.0', + sourceSha: 'deadbeefcafe', + }); + + const rootManifest = JSON.parse(fs.readFileSync(rootPath, 'utf8')); + const packageManifest = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + + assert.equal(rootManifest.updated_at, packageManifest.updated_at); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test('stageManifestsReadyToPublish: sets build_run_id/source_ref/source_sha on root manifest only', () => { + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'test-')); + try { + const rootPath = path.join(tmpDir, 'root-manifest.json'); + const packagePath = path.join(tmpDir, 'package-manifest.json'); + + fs.writeFileSync(rootPath, JSON.stringify(createManifest(), null, 2) + '\n'); + fs.writeFileSync(packagePath, JSON.stringify(createPackageManifest(), null, 2) + '\n'); + + stageManifestsReadyToPublish({ + rootPath, + packagePath, + buildRunId: '12345', + sourceRef: 'rust-v0.145.0', + sourceSha: 'deadbeefcafe', + }); + + const rootManifest = JSON.parse(fs.readFileSync(rootPath, 'utf8')); + const packageManifest = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + + assert.equal(rootManifest.build_run_id, '12345'); + assert.equal(rootManifest.source_ref, 'rust-v0.145.0'); + assert.equal(rootManifest.source_sha, 'deadbeefcafe'); + + // package manifest schema does not carry these fields; must not be added + assert.equal(packageManifest.build_run_id, undefined); + assert.equal(packageManifest.source_ref, undefined); + assert.equal(packageManifest.source_sha, undefined); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test('stageManifestsReadyToPublish: preserves package-specific fields', () => { + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'test-')); + try { + const rootPath = path.join(tmpDir, 'root-manifest.json'); + const packagePath = path.join(tmpDir, 'package-manifest.json'); + + fs.writeFileSync(rootPath, JSON.stringify(createManifest(), null, 2) + '\n'); + fs.writeFileSync(packagePath, JSON.stringify(createPackageManifest({ + update_command: 'npm install -g @bash0816/codex-termux@latest', + }), null, 2) + '\n'); + + stageManifestsReadyToPublish({ + rootPath, + packagePath, + buildRunId: '12345', + sourceRef: 'rust-v0.145.0', + sourceSha: 'deadbeefcafe', + }); + + const packageManifest = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + assert.equal(packageManifest.update_command, 'npm install -g @bash0816/codex-termux@latest'); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test('stageManifestsReadyToPublish: throws if a required parameter is missing', () => { + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'test-')); + try { + const rootPath = path.join(tmpDir, 'root-manifest.json'); + const packagePath = path.join(tmpDir, 'package-manifest.json'); + fs.writeFileSync(rootPath, JSON.stringify(createManifest(), null, 2) + '\n'); + fs.writeFileSync(packagePath, JSON.stringify(createPackageManifest(), null, 2) + '\n'); + + assert.throws(() => { + stageManifestsReadyToPublish({ + rootPath, + packagePath, + buildRunId: '12345', + sourceRef: 'rust-v0.145.0', + // sourceSha missing + }); + }); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test('CLI: stage subcommand rejects wrong argument count with non-zero exit', () => { + const scriptPath = path.join(__dirname, 'update-codex-candidate-manifest.js'); + const result = spawnSync(process.execPath, [scriptPath, 'stage', 'only-build-run-id'], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); +}); + +test('CLI: legacy two-argument form rejects wrong argument count with non-zero exit', () => { + const scriptPath = path.join(__dirname, 'update-codex-candidate-manifest.js'); + const result = spawnSync(process.execPath, [scriptPath, '0.145.0'], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); +});