diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index f58acff65b..b316ed6708 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -63,10 +63,14 @@ jobs: # This job never runs on `pull_request`, so the fork-safety clause is always true here; # it is kept verbatim so the expression stays greppable against ci.yml. runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} + outputs: + version: ${{ steps.resolve.outputs.version }} + tag: ${{ steps.resolve.outputs.tag }} + skip: ${{ steps.resolve.outputs.skip }} permissions: actions: read # find + download the CI run's next-build artifact for this SHA contents: write # gh release upload (attach SBOM to the GitHub Release) - id-token: write # npm provenance + id-token: write # npm provenance (GitHub Packages step) packages: write # publish to npm.pkg.github.com steps: - name: Checkout @@ -314,41 +318,33 @@ jobs: fi npm --version - - name: Publish to npm (staged — owner approves with 2FA) - if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct') + # The registry upload itself moved to the `stage-npm` job below: npm REFUSES + # `--provenance` from a self-hosted runner (422 "Unsupported GitHub Actions + # runner environment"), and the heavy verification above cannot move to a + # hosted one (16 GB is not enough for build:cli's next-build fallback — see + # this job's runs-on comment). So this job proves the bytes and hands them + # over; a tiny hosted job does the upload. + - name: Pack the verified tarball for the upload job + if: steps.resolve.outputs.skip != 'true' env: VERSION: ${{ steps.resolve.outputs.version }} - TAG: ${{ steps.resolve.outputs.tag }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail - # Always pass --tag explicitly. Defense in depth: even if VERSION is - # accidentally an older release, the historic tag will NOT claim `@latest`. - npm stage publish --provenance --access public --tag "$TAG" - { - echo "## 📦 omniroute@$VERSION STAGED (not yet installable)" - echo "" - echo "The exact bytes are parked on the registry. To release them:" - echo '```' - echo "npm stage list omniroute # find the stage id" - echo "npm stage approve # owner 2FA — THE publish" - echo '```' - echo "To verify the staged bytes first: npm stage download → run" - echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)." - echo "To discard: npm stage reject ." - } >> "$GITHUB_STEP_SUMMARY" - echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'" + # --ignore-scripts: prepublishOnly would re-run build:cli-api && build:cli, + # rebuilding bytes this job has already built, validated and boot-smoked. + npm pack --ignore-scripts + TARBALL="omniroute-${VERSION}.tgz" + test -f "$TARBALL" || { echo "expected $TARBALL to exist after npm pack" >&2; ls -la ./*.tgz || true; exit 1; } + echo "packed $TARBALL ($(du -h "$TARBALL" | cut -f1))" - - name: Publish to npm (DIRECT — emergency fallback) - if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct' - env: - VERSION: ${{ steps.resolve.outputs.version }} - TAG: ${{ steps.resolve.outputs.tag }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - set -euo pipefail - npm publish --provenance --access public --tag "$TAG" - echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]" + - name: Hand the tarball to the hosted publish job + if: steps.resolve.outputs.skip != 'true' + uses: actions/upload-artifact@v7 + with: + name: npm-tarball + path: omniroute-${{ steps.resolve.outputs.version }}.tgz + retention-days: 1 + if-no-files-found: error - name: Publish to GitHub Packages if: steps.resolve.outputs.skip != 'true' @@ -365,6 +361,91 @@ jobs: || echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages." echo "✅ Action finished for GitHub Packages" + # npm REFUSES `--provenance` from a self-hosted runner: + # 422 Unprocessable Entity - Error verifying sigstore provenance bundle: + # Unsupported GitHub Actions runner environment: "self-hosted". + # Only "github-hosted" runners are supported when publishing with provenance. + # v3.8.49 published fine because it predates USE_VPS_RUNNER being turned on + # (2026-08-02); v3.8.50 was the first release after it, so this had been latent + # for four weeks. Dropping --provenance was not an option: 3.8.49 carries a + # SLSA attestation and 3.8.50 must not regress that. + # The `publish` job cannot simply move to a hosted runner either — 16 GB is not + # enough for build:cli's next-build fallback. So it keeps proving the bytes and + # this job, which needs no memory at all, performs the upload. + stage-npm: + needs: publish + if: needs.publish.outputs.skip != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # npm provenance — the whole reason this job is separate + steps: + - name: Download the tarball the publish job proved + uses: actions/download-artifact@v8 + with: + name: npm-tarball + path: . + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }} + registry-url: https://registry.npmjs.org + + - name: Ensure npm supports staged publishing + run: | + set -euo pipefail + CUR=$(npm --version) + if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then + # Pinned exact version (supply-chain: never float @latest in a publish + # job); bump deliberately when a newer npm is required. + echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing" + npm install -g --ignore-scripts npm@11.15.0 + fi + npm --version + + - name: Publish to npm (staged — owner approves with 2FA) + if: github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct' + env: + VERSION: ${{ needs.publish.outputs.version }} + TAG: ${{ needs.publish.outputs.tag }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + TARBALL="omniroute-${VERSION}.tgz" + test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; } + # Always pass --tag explicitly. Defense in depth: even if VERSION is + # accidentally an older release, the historic tag will NOT claim `@latest`. + # --ignore-scripts: publishing a built tarball must never re-run + # prepublishOnly (build:cli-api && build:cli) on this small runner. + npm stage publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts + { + echo "## 📦 omniroute@$VERSION STAGED (not yet installable)" + echo "" + echo "The exact bytes are parked on the registry. To release them:" + echo '```' + echo "npm stage list omniroute # find the stage id" + echo "npm stage approve # owner 2FA — THE publish" + echo '```' + echo "To verify the staged bytes first: npm stage download → run" + echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)." + echo "To discard: npm stage reject ." + } >> "$GITHUB_STEP_SUMMARY" + echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'" + + - name: Publish to npm (DIRECT — emergency fallback) + if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct' + env: + VERSION: ${{ needs.publish.outputs.version }} + TAG: ${{ needs.publish.outputs.tag }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + TARBALL="omniroute-${VERSION}.tgz" + test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; } + npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts + echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]" + publish-opencode-plugin: runs-on: ubuntu-latest permissions: diff --git a/changelog.d/fixes/11868-npm-provenance-hosted-runner.md b/changelog.d/fixes/11868-npm-provenance-hosted-runner.md new file mode 100644 index 0000000000..4cc94111ce --- /dev/null +++ b/changelog.d/fixes/11868-npm-provenance-hosted-runner.md @@ -0,0 +1,4 @@ +- Split the npm registry upload into its own GitHub-hosted job. npm refuses + `--provenance` from a self-hosted runner (`422 ... Only "github-hosted" runners + are supported`), which blocked the v3.8.50 publish; the heavy verification + cannot move to a hosted runner, so it now hands the proven tarball over instead. diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 2edc9b51e6..5f42689346 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -169,7 +169,7 @@ "dedicatedGate": true }, "zizmorFindings": { - "value": 192, + "value": 194, "_rebaseline_2026_08_20_radar_export_workflow": "190 -> 192 (+2). Workflow novo `.github/workflows/radar-export.yml` (passo 10 do go-live do Radar: publica o export estável do catálogo como asset de release para o servidor privado baixar via RADAR_EXPORT_URL). Os +2 são unpinned-uses @vN: actions/checkout@v7 + actions/setup-node@v7 — a MESMA convenção deliberada de todos os workflows (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só este violaria a convenção. O findings artipacked do checkout foi CORRIGIDO com `persist-credentials: false` (o job publica via GH_TOKEN em `gh release`, não usa a credencial do checkout). Nenhuma classe nova de template-injection / cache-poisoning / dangerous-triggers. Medido local com zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 191; +1 do delta conhecido do runner (ver _rebaseline_2026_07_28_ci_runner_delta: o runner enxerga 1 unpinned-uses @vN a mais que o devbox no mesmo commit; a baseline segue o runner) => 192.", "_rebaseline_2026_07_20_aliasresolver_hook_split_7808": "175 -> 176 (+1). Companion to PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix in bin/aliasResolver.mjs). The +1 is NOT caused by this PR's code changes (bin/* is not a workflow file) — it is a pre-existing drift that surfaced because the ratchet gate runs on this PR's CI: the zizmor scanner version on the GitHub runner gained a new rule (or extended an existing one) since the v3.8.49 baseline was seeded on 2026-07-17. Breakdown: the new finding is an unpinned-uses @vN class item on one of the existing workflows (same deliberate convention as _scanner_harden_workflows_2026_06_16 — @vN is intentional, SHA-pinning only this one would violate the convention). No new template-injection/artipacked/cache-poisoning/dangerous-triggers classes introduced. Measured by the Quality Gates (Extended) job on run 29713001401 = 176, baseline was 175. Note: by the time this landed on release/v3.8.49, the baseline was already at 176 via _rebaseline_2026_07_17_combo_recovery_hints — this entry is kept as historical record; no further bump applied.", "_rebaseline_2026_07_17_v3849_release": "169 -> 175 (+6). Cycle workflow drift (v3.8.48/v3.8.49): npm-publish.yml (new, WS1.3 #7092), electron-release.yml, nightly-compat.yml, nightly-release-green.yml, CI restructures (#7501 full-history base fetch, #7355 main-green, #7202 merge-queue gates, Trunk/Codecov). Breakdown vs v3.8.47: +3 unpinned-uses (@vN convention, deliberate per _scanner_harden_workflows_2026_06_16), +2 cache-poisoning (artifact upload/cache in the OWN electron-release/npm-publish RELEASE workflows -- operator-controlled, not fork-PR exploitable), +1 excessive-permissions (nightly-compat.yml permissions:issues). No new template-injection/artipacked/dangerous-triggers. Measured with zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 175 on da3a0be69.", @@ -180,7 +180,8 @@ "_rebaseline_2026_06_23_v3834_release": "152 -> 155 (+3). The 3 new unpinned-uses are in .github/workflows/nightly-release-green.yml (added by #4622 this cycle): actions/checkout@v7, actions/setup-node@v6, actions/upload-artifact@v4 — the SAME deliberate @vN convention as ci.yml's own checkout@v7/setup-node@v6 and every other workflow (see _scanner_harden_workflows_2026_06_16 + _zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse). SHA-pinning only this workflow would violate the convention. The workflow-lint ratchet does NOT run on PR->release fast-gates, so it surfaced only on the release PR; measured locally via `npm run check:workflows -- --ratchet` = 155. No new template-injection/artipacked/cache-poisoning.", "_rebaseline_2026_07_13_v3847_release_preflight": "159 -> 169 (+10). Findings from cycle-merged workflow changes: #6716 (PR gate restructure), #6781 (unit fast-path shard 2->4), #6788 (TIA tsx loader split), #6881 (electron-updater latest.yml manifests in release assets) — same deliberate @vN unpinned-uses convention as prior rebaselines; no new template-injection/artipacked/cache-poisoning classes. Measured via `npm run check:workflows -- --ratchet` = 169 on the v3.8.47 release pre-flight.", "_rebaseline_2026_07_28_v3849_release_preflight": "176 -> 189 (+13). Pre-flight de fechamento da v3.8.49 (934 commits no ciclo). Deriva de workflow: 1 workflow novo (build-rinseaid-image.yml) mais os bumps de action do Dependabot ao longo do ciclo — todos da MESMA classe unpinned-uses @vN, convenção deliberada do repo (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só estes violaria a convenção. Nenhuma classe nova de template-injection / artipacked / cache-poisoning / dangerous-triggers. Nesta mesma passada foram CORRIGIDAS 3 diretivas shellcheck malformadas (SC1125: `# shellcheck disable=SC2086 — texto`, em que o travessão invalida o par key=value) em ci.yml e nightly-release-green.yml. Medido com zizmor 1.25.2 via `npm run check:workflows -- --ratchet` = 189.", - "_rebaseline_2026_07_28_ci_runner_delta": "189 -> 190 (+1). Medido 189 no devbox e 190 no runner do GitHub no MESMO commit (run 30396592013, job Quality Gates (Extended)) — mesma classe já registrada em _rebaseline_2026_07_20_aliasresolver_hook_split_7808: a versão do zizmor no runner enxerga uma finding a mais que a local, sempre da classe unpinned-uses @vN. O valor do runner é o que o gate compara, então a baseline segue o runner." + "_rebaseline_2026_07_28_ci_runner_delta": "189 -> 190 (+1). Medido 189 no devbox e 190 no runner do GitHub no MESMO commit (run 30396592013, job Quality Gates (Extended)) — mesma classe já registrada em _rebaseline_2026_07_20_aliasresolver_hook_split_7808: a versão do zizmor no runner enxerga uma finding a mais que a local, sempre da classe unpinned-uses @vN. O valor do runner é o que o gate compara, então a baseline segue o runner.", + "_rebaseline_2026_08_28_npm_publish_hosted_stage_job": "192 -> 194 (+2). Job novo `stage-npm` em .github/workflows/npm-publish.yml: o npm RECUSA `--provenance` vindo de runner self-hosted (422 \"Unsupported GitHub Actions runner environment\"), e o job `publish` nao pode migrar para runner hospedado porque 16 GB nao bastam para o fallback next-build do build:cli (documentado no proprio runs-on). A separacao foi a unica saida que preserva a atestacao SLSA que a 3.8.49 ja tem. Os +2 sao da MESMA convencao deliberada de todos os workflows (ver _scanner_harden_workflows_2026_06_16): unpinned-uses @vN em actions/download-artifact@v8 + actions/setup-node@v7, mais o cache-poisoning que o proprio setup-node@v7 ja gera nos outros 2 jobs deste MESMO arquivo (linhas 85 e 463) e que ja esta na baseline. Fixar por SHA so este job violaria a convencao. Nenhuma classe nova: zero template-injection / artipacked / dangerous-triggers / excessive-permissions — o job declara apenas contents:read + id-token:write, que e o minimo para a proveniencia. Medido pelo job Quality Gates (Extended) no run 33162... da PR #11877 = 194." }, "vulnCount": { "value": 22,