From 962a406d9e83e60b1206544e01e4ade2a37dec2e Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 5 Sep 2026 13:44:31 -0700 Subject: [PATCH] fix(release): include maintenance notes without triggering releases Show documentation, build, and CI entries in eligible release notes and always refresh pending release branches. Use a pinned read-only eligibility planner isolated from App credentials, bind it to main, and separate release creation from PR creation. Validation: pinned policy and workflow tests plus live read-only eligibility planning. The installed pre-commit hook runs the full scripts/ci-preflight.sh on the exact staged tree before recording this commit. Signed-off-by: jatmn --- .github/workflows/release-please.yml | 45 ++- AGENTS.md | 13 +- docs/release-automation-plan.md | 16 +- docs/release-maintainer-runbook.md | 13 + docs/releases.md | 10 +- release-please-config.json | 7 +- tools/release-please-policy/SECURITY.md | 14 +- .../fixtures/version-policy.json | 8 +- tools/release-please-policy/harness.mjs | 264 ++++++++++++++---- .../validate-workflows.mjs | 63 ++++- 10 files changed, 371 insertions(+), 82 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 820e8be..fbe3fcc 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -20,11 +20,16 @@ jobs: actions: read attestations: read contents: read + pull-requests: read outputs: ready: ${{ steps.gate.outputs.ready }} + releasable: ${{ steps.release-trigger.outputs.releasable }} + source_sha: ${{ steps.release-trigger.outputs.source_sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: main + fetch-depth: 0 persist-credentials: false - id: gate env: @@ -41,6 +46,26 @@ jobs: fi OFFICIAL_ALLOW_MISSING_LATEST_TAG=1 bash scripts/check-prior-official-releases.sh echo 'ready=true' >>"$GITHUB_OUTPUT" + - name: Install pinned Node for release eligibility + if: steps.gate.outputs.ready == 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.20.0 + - name: Install locked release policy dependencies + if: steps.gate.outputs.ready == 'true' + working-directory: tools/release-please-policy + run: npm ci --ignore-scripts --no-audit --no-fund + - id: release-trigger + name: Check release eligibility without changing release notes + if: steps.gate.outputs.ready == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + planned_main="$(git rev-parse HEAD)" + node tools/release-please-policy/harness.mjs --release-trigger + live_main="$(git ls-remote --exit-code origin refs/heads/main | cut -f1)" + [ "$planned_main" = "$live_main" ] || { echo 'main changed during eligibility planning' >&2; exit 1; } + echo "source_sha=$planned_main" >>"$GITHUB_OUTPUT" release-please: needs: gate @@ -60,8 +85,8 @@ jobs: sha: ${{ steps.release.outputs.sha }} html_url: ${{ steps.release.outputs.html_url }} upload_url: ${{ steps.release.outputs.upload_url }} - pr: ${{ steps.release.outputs.pr }} - prs_created: ${{ steps.release.outputs.prs_created }} + pr: ${{ steps.release-pr.outputs.pr }} + prs_created: ${{ steps.release-pr.outputs.prs_created }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -71,10 +96,12 @@ jobs: - name: Revalidate protected state before reading the App key env: GH_TOKEN: ${{ github.token }} + ELIGIBILITY_SOURCE_SHA: ${{ needs.gate.outputs.source_sha }} run: | [ "${{ github.ref }}" = refs/heads/main ] git fetch --no-tags origin main live_main="$(git rev-parse origin/main)" + [ "$live_main" = "$ELIGIBILITY_SOURCE_SHA" ] || { echo 'main changed after eligibility planning; rerun from main' >&2; exit 1; } git checkout --detach "$live_main" [ "$(git rev-parse HEAD)" = "$live_main" ] [ "${{ vars.OFFICIAL_RELEASES_ENABLED }}" = true ] @@ -108,6 +135,16 @@ jobs: target-branch: main config-file: release-please-config.json manifest-file: .release-please-manifest.json + skip-github-pull-request: true + - id: release-pr + if: steps.missing-draft.outputs.created != 'true' && steps.release.outputs.releases_created != 'true' && needs.gate.outputs.releasable == 'true' + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.app-token.outputs.token }} + target-branch: main + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: true - name: Validate and summarize result env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -121,8 +158,8 @@ jobs: TAG: ${{ steps.release.outputs.tag_name }} SHA: ${{ steps.release.outputs.sha }} URL: ${{ steps.release.outputs.html_url }} - PRS_CREATED: ${{ steps.release.outputs.prs_created }} - PR: ${{ steps.release.outputs.pr }} + PRS_CREATED: ${{ steps.release-pr.outputs.prs_created }} + PR: ${{ steps.release-pr.outputs.pr }} shell: bash run: | if [ "$MISSING_CREATED" = true ]; then diff --git a/AGENTS.md b/AGENTS.md index 0e81723..0fc7146 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,10 +373,17 @@ release: Breaking changes use `type!:` or a body footer `BREAKING CHANGE: ...`. Before 1.0 that still bumps minor. -These types stay in git history. They do not write release notes and do not -bump the official version by themselves: +These types appear in release notes when an eligible change opens a release, +but do not open a release or bump the official version by themselves: -`refactor`, `docs`, `test`, `build`, `ci`, `chore` +`docs` (Documentation), `build` (Build System), `ci` (Continuous Integration). +The workflow checks release eligibility separately from note visibility using +the pinned Release Please planner. Breaking changes still follow the policy +above. Eligible pending release branches refresh from `main` even when their +notes have not changed. + +`refactor`, `test`, and `chore` remain hidden from ordinary release notes and +do not open a release by themselves. Pick the type from the user-visible behavior, not the files touched. A Web UI bug that also updates docs is still `fix(webui):`, not `docs:`. A catalog-only diff --git a/docs/release-automation-plan.md b/docs/release-automation-plan.md index f30b0bd..a929587 100644 --- a/docs/release-automation-plan.md +++ b/docs/release-automation-plan.md @@ -219,9 +219,19 @@ feat!: change the provider selection contract changelog configuration, contributors must use `fix(revert): ...` and the accepted-type policy must be revised before automation is enabled. - `!` or `BREAKING CHANGE:` → breaking-version policy. -- `docs`, `test`, `build`, `ci`, `chore`, and `refactor` appear only in the - configured changelog sections and do not independently force a release unless - explicitly configured. +- Ordinary `docs`, `build`, and `ci` changes appear in the configured + changelog sections of an eligible release but do not independently force a + release. A read-only pinned Manifest planner hides those sections only while + deciding PR eligibility; the action retains them in the actual notes. +- Ordinary `test`, `chore`, and `refactor` changes remain hidden and do not + independently force a release. +- Enable `always-update` so an eligible release branch refreshes from `main` + even when the generated notes are unchanged. +- Eligibility suppresses only release PR creation, never the action that tags + an already merged release. A pending merged release is completed before any + new PR is planned. Tag/draft creation and PR creation run in separate action + steps; creating a release skips the PR step even if eligibility was computed + before the release PR merged. - Dependabot titles such as `build(deps): ...` remain valid. - Release Please's own `chore(main): release X.Y.Z` title remains valid. - Treat these effects as policy assertions, not assumptions about Release diff --git a/docs/release-maintainer-runbook.md b/docs/release-maintainer-runbook.md index f94b4d9..6cc182b 100644 --- a/docs/release-maintainer-runbook.md +++ b/docs/release-maintainer-runbook.md @@ -150,6 +150,19 @@ campaign is complete. ## 7. Normal Operations +The release worker includes documentation, build, and CI entries in an +eligible release, while a read-only pinned planner prevents those ordinary +changes from opening a release on their own. It runs in the existing gate job +with the read-only workflow token and no protected environment. The protected +job verifies that `main` still matches the eligibility result before minting +the App token. An eligibility error or source drift fails the run; rerun from +`main` after investigating the failure. + +The `always-update` setting refreshes eligible release branches even when +their notes are unchanged. Merge release-policy changes through an ordinary +PR before merging the pending release PR, then verify the worker's regenerated +notes and checks. The bot PR's four-file allowlist must remain intact. + To cut an official release, review and merge the Release Please PR. Do not manually create its tag or publish its draft. Confirm the App-created tag starts one Release workflow, all four builds pass, the eleven assets verify, and the diff --git a/docs/releases.md b/docs/releases.md index 61e2cc5..30a2f96 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -103,8 +103,14 @@ Please reads for changelog type. Use the templates in - `fix:` and `perf:` request a patch release; - `feat:` requests a minor release; - `type!:` or a `BREAKING CHANGE:` footer requests a breaking release; and -- documentation, tests, build, CI, refactors, and chores are normally hidden - from release notes and do not independently request a release. +- documentation, build, and CI changes appear in their own sections when an + eligible change requests a release, but do not independently request one; +- tests, refactors, and chores are normally hidden from release notes and do + not independently request a release. + +Release eligibility is checked separately from note visibility. Once a release +is eligible, Release Please refreshes its branch from `main` even when the +notes have not changed. Before 1.0, this repository deliberately bumps minor for breaking changes. Maintainers can use the documented `Release-As: X.Y.Z` footer for an exceptional diff --git a/release-please-config.json b/release-please-config.json index 8012466..4c9aafd 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -7,16 +7,17 @@ "bump-minor-pre-major": true, "draft": true, "force-tag-creation": true, + "always-update": true, "changelog-sections": [ { "type": "feat", "section": "Features" }, { "type": "fix", "section": "Bug Fixes" }, { "type": "perf", "section": "Performance Improvements" }, { "type": "revert", "section": "Reverts" }, { "type": "refactor", "section": "Code Refactoring", "hidden": true }, - { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "docs", "section": "Documentation" }, { "type": "test", "section": "Tests", "hidden": true }, - { "type": "build", "section": "Build System", "hidden": true }, - { "type": "ci", "section": "Continuous Integration", "hidden": true }, + { "type": "build", "section": "Build System" }, + { "type": "ci", "section": "Continuous Integration" }, { "type": "chore", "section": "Miscellaneous", "hidden": true } ], "packages": { diff --git a/tools/release-please-policy/SECURITY.md b/tools/release-please-policy/SECURITY.md index 2263565..c807c27 100644 --- a/tools/release-please-policy/SECURITY.md +++ b/tools/release-please-policy/SECURITY.md @@ -17,12 +17,16 @@ Rollup-generated CommonJS bundle, not hidden source. Review on 2026-08-30 found: - the upstream release contains one entry-point compatibility fix; and - npm reports a registry signature for the published package. -`release-please` is intentionally a dev dependency used only by the pinned -policy harness. Release, nightly, and recovery workflows install with +`release-please` is intentionally a dev dependency used by the pinned +policy harness and the isolated read-only Release Please eligibility job. +Credential-adjacent release, nightly, and recovery jobs install with `npm ci --omit=dev --ignore-scripts`, so neither Release Please nor yargs is -installed in credential-adjacent jobs. The all-dependencies harness also uses -`--ignore-scripts` and runs with read-only repository credentials and no -protected environment secret. +installed in credential-adjacent jobs. The all-dependencies harness and +eligibility job use `--ignore-scripts` and run with read-only repository +credentials and no protected environment secret. Eligibility passes only a +boolean and its inspected source SHA to the protected job; it does not pass +dependencies or executable artifacts. The protected job rejects a changed +`main` before minting the App token. The exact yargs version and integrity are asserted by the offline policy tests. Changing either requires a new source and supply-chain review. diff --git a/tools/release-please-policy/fixtures/version-policy.json b/tools/release-please-policy/fixtures/version-policy.json index d5152f6..c31f8f8 100644 --- a/tools/release-please-policy/fixtures/version-policy.json +++ b/tools/release-please-policy/fixtures/version-policy.json @@ -8,11 +8,11 @@ {"name": "performance", "message": "perf(codec): reduce scanning allocations", "version": "0.0.2", "section": "Performance Improvements"}, {"name": "revert", "message": "revert: restore the previous retry policy", "version": "0.0.2", "section": "Reverts"}, {"name": "refactor", "message": "refactor: split the transport helper", "version": null, "section": null}, - {"name": "documentation", "message": "docs: clarify provider setup", "version": null, "section": null}, + {"name": "documentation", "message": "docs: clarify provider setup", "version": null, "section": "Documentation"}, {"name": "test", "message": "test: cover retry exhaustion", "version": null, "section": null}, - {"name": "build", "message": "build: update linker flags", "version": null, "section": null}, - {"name": "dependabot", "message": "build(deps): bump serde from 1.0.1 to 1.0.2", "version": null, "section": null}, - {"name": "ci", "message": "ci: split the Windows job", "version": null, "section": null}, + {"name": "build", "message": "build: update linker flags", "version": null, "section": "Build System"}, + {"name": "dependabot", "message": "build(deps): bump serde from 1.0.1 to 1.0.2", "version": null, "section": "Build System"}, + {"name": "ci", "message": "ci: split the Windows job", "version": null, "section": "Continuous Integration"}, {"name": "chore", "message": "chore: refresh fixtures", "version": null, "section": null}, {"name": "breaking bang", "message": "feat!: change the provider contract", "version": "0.1.0", "section": "Features"}, {"name": "breaking footer", "message": "fix: change the provider contract\n\nBREAKING CHANGE: providers now require an explicit name", "version": "0.1.0", "section": "Bug Fixes"}, diff --git a/tools/release-please-policy/harness.mjs b/tools/release-please-policy/harness.mjs index e84aec0..3cf9571 100644 --- a/tools/release-please-policy/harness.mjs +++ b/tools/release-please-policy/harness.mjs @@ -29,62 +29,218 @@ const {DefaultChangelogNotes} = require( 'release-please/build/src/changelog-notes/default.js' ); -const config = JSON.parse(fs.readFileSync(path.join(root, 'release-please-config.json'))); -const vendoredSchema = JSON.parse( - fs.readFileSync(path.join(here, 'config.schema.json')) -); -assert.deepEqual( - vendoredSchema, - releasePlease.configSchema, - 'vendored config schema drifted from release-please 17.6.0' -); -const ajv = new Ajv({allErrors: true, strict: false}); -addFormats(ajv); -assert.equal( - ajv.validate(vendoredSchema, config), - true, - ajv.errorsText(ajv.errors, {separator: '\n'}) -); +const {GitHub, Manifest} = releasePlease; -const fixture = JSON.parse( - fs.readFileSync(path.join(here, 'fixtures/version-policy.json')) -); -const changelogSections = config['changelog-sections']; -const versioning = new DefaultVersioningStrategy({bumpMinorPreMajor: true}); -const notesBuilder = new DefaultChangelogNotes(); - -for (const [index, testCase] of fixture.cases.entries()) { - const commits = parseConventionalCommits([ - { - sha: String(index + 1).padStart(40, '0'), - message: testCase.message, - }, - ]); - assert.ok(commits.length > 0, `${testCase.name}: parser returned no commits`); - - const notes = await notesBuilder.buildNotes(commits, { - owner: 'jatmn', - repository: 'Codex-warp', - version: testCase.version ?? fixture.baseVersion, - currentTag: `v${testCase.version ?? fixture.baseVersion}`, - targetBranch: 'main', - changelogSections, - }); - const visible = notes.split('\n').length > 1; - - if (testCase.version === null) { - assert.equal(visible, false, `${testCase.name}: expected no release notes`); - continue; +const notesOnlyTypes = new Set(['docs', 'build', 'ci']); + +// Use a fresh, read-only Manifest for eligibility. The action constructs its +// own Manifest with all visible sections when it generates the actual notes. +async function hasReleasableChanges(manifest) { + for (const config of Object.values(manifest.repositoryConfig)) { + config.changelogSections = config.changelogSections.map(section => + notesOnlyTypes.has(section.type) ? {...section, hidden: true} : section + ); } + // A merged release may not have its tag yet. Let the action finish that + // release, without bootstrapping another PR from the previous release range. + if ((await manifest.buildReleases()).length > 0) return false; + return (await manifest.buildPullRequests()).length > 0; +} - assert.equal(visible, true, `${testCase.name}: expected visible release notes`); - const next = versioning.bump(Version.parse(fixture.baseVersion), commits); - assert.equal(next.toString(), testCase.version, `${testCase.name}: version`); - assert.match( - notes, - new RegExp(`^### ${testCase.section.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}$`, 'm'), - `${testCase.name}: changelog section` - ); +async function main() { + const policy = JSON.parse(fs.readFileSync( + new URL('../release-automation-policy.json', import.meta.url), 'utf8' + )); + assert.equal(process.env.GITHUB_REPOSITORY, policy.repository); + assert.equal(process.env.GITHUB_REF, `refs/heads/${policy.baseBranch}`); + assert.ok(process.env.GH_TOKEN, 'read-only GitHub token is required'); + assert.ok(process.env.GITHUB_OUTPUT, 'GitHub output file is required'); + const [owner, repo] = policy.repository.split('/'); + const github = await GitHub.create({owner, repo, token: process.env.GH_TOKEN}); + const manifest = await Manifest.fromManifest(github, policy.baseBranch); + const releasable = await hasReleasableChanges(manifest); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `releasable=${releasable}\n`); } -console.log(`release-please-policy-harness: ${fixture.cases.length} cases ok`); + +if (process.argv[2] === '--release-trigger') { + assert.equal(process.argv.length, 3, 'unexpected release-trigger arguments'); + await main(); +} else { + assert.equal(process.argv.length, 2, 'unexpected policy harness arguments'); + const config = JSON.parse(fs.readFileSync(path.join(root, 'release-please-config.json'))); + const vendoredSchema = JSON.parse( + fs.readFileSync(path.join(here, 'config.schema.json')) + ); + assert.deepEqual( + vendoredSchema, + releasePlease.configSchema, + 'vendored config schema drifted from release-please 17.6.0' + ); + const ajv = new Ajv({allErrors: true, strict: false}); + addFormats(ajv); + assert.equal( + ajv.validate(vendoredSchema, config), + true, + ajv.errorsText(ajv.errors, {separator: '\n'}) + ); + + const fixture = JSON.parse( + fs.readFileSync(path.join(here, 'fixtures/version-policy.json')) + ); + const changelogSections = config['changelog-sections']; + const versioning = new DefaultVersioningStrategy({bumpMinorPreMajor: true}); + const notesBuilder = new DefaultChangelogNotes(); + + for (const [index, testCase] of fixture.cases.entries()) { + const commits = parseConventionalCommits([ + { + sha: String(index + 1).padStart(40, '0'), + message: testCase.message, + }, + ]); + assert.ok(commits.length > 0, `${testCase.name}: parser returned no commits`); + + const notes = await notesBuilder.buildNotes(commits, { + owner: 'jatmn', + repository: 'Codex-warp', + version: testCase.version ?? fixture.baseVersion, + currentTag: `v${testCase.version ?? fixture.baseVersion}`, + targetBranch: 'main', + changelogSections, + }); + const visible = notes.split('\n').length > 1; + + if (testCase.version === null) { + assert.equal(visible, testCase.section !== null, `${testCase.name}: notes visibility`); + if (testCase.section !== null) { + assert.ok(notes.includes(`### ${testCase.section}\n`), `${testCase.name}: notes section`); + } + continue; + } + + assert.equal(visible, true, `${testCase.name}: expected visible release notes`); + const next = versioning.bump(Version.parse(fixture.baseVersion), commits); + assert.equal(next.toString(), testCase.version, `${testCase.name}: version`); + assert.match( + notes, + new RegExp(`^### ${testCase.section.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}$`, 'm'), + `${testCase.name}: changelog section` + ); + } + + console.log(`release-please-policy-harness: ${fixture.cases.length} cases ok`); + + + + const baseSha = 'a'.repeat(40); + assert.equal(config['always-update'], true); + + // Replace only GitHub I/O. Version selection, commit parsing, release-PR + // generation, pending-release discovery, and update selection use the pin. + async function history(messages, options = {}) { + const state = {open: [], merged: [], updates: []}; + const github = { + repository: {owner: 'jatmn', repo: 'Codex-warp', defaultBranch: 'main'}, + async getFileJson(name) { + if (name === 'release-please-config.json') { + return {...structuredClone(config), ...options}; + } + assert.equal(name, '.release-please-manifest.json'); + return {'.': fixture.baseVersion}; + }, + async getFileContentsOnBranch(name) { + assert.equal(name, 'Cargo.toml'); + return { + parsedContent: `[package]\nname = "codex-warp"\nversion = "${fixture.baseVersion}"\n`, + content: '', sha: baseSha, + }; + }, + async *releaseIterator() { + yield {tagName: `v${fixture.baseVersion}`, sha: baseSha, notes: ''}; + }, + async *mergeCommitIterator() { + for (const [index, message] of messages.entries()) { + yield {sha: String(index + 1).padStart(40, '0'), message, files: ['README.md']}; + } + yield {sha: baseSha, message: `chore(main): release ${fixture.baseVersion}`, files: []}; + }, + async *pullRequestIterator(_branch, status) { + yield* status === 'OPEN' ? state.open : status === 'MERGED' ? state.merged : []; + }, + async updatePullRequest(number, proposal, targetBranch) { + assert.equal(targetBranch, 'main'); + state.updates.push({number, proposal}); + return state.open.find(pr => pr.number === number); + }, + }; + return {github, state, manifest: await Manifest.fromManifest(github, 'main')}; + } + + for (const testCase of fixture.cases) { + const {manifest} = await history([testCase.message]); + assert.equal( + await hasReleasableChanges(manifest), testCase.version !== null, + `${testCase.name}: release eligibility` + ); + if (testCase.version !== null) { + const actual = await (await history([testCase.message])).manifest.buildPullRequests(); + assert.equal(actual.length, 1, `${testCase.name}: candidate count`); + assert.equal(actual[0].version.toString(), testCase.version, `${testCase.name}: candidate version`); + } + } + + const maintenance = [ + 'docs: clarify provider setup', + 'build(deps): update the parser', + 'ci: update the workflow', + ]; + assert.equal(await hasReleasableChanges((await history(maintenance)).manifest), false); + const mixed = ['fix: preserve stream usage', ...maintenance]; + assert.equal(await hasReleasableChanges((await history(mixed)).manifest), true); + const proposals = await (await history(mixed)).manifest.buildPullRequests(); + assert.equal(proposals.length, 1); + assert.equal(proposals[0].version.toString(), '0.0.2'); + const body = proposals[0].body.toString(); + for (const section of ['Bug Fixes', 'Documentation', 'Build System', 'Continuous Integration']) { + assert.ok(body.includes(`### ${section}\n`), `mixed history: ${section}`); + } + assert.deepEqual( + proposals[0].updates.map(update => update.path).sort(), + ['.release-please-manifest.json', 'CHANGELOG.md', 'Cargo.lock', 'Cargo.toml'] + ); + for (const type of ['docs', 'build', 'ci']) { + const breaking = [`${type}!: change the supported contract`]; + assert.equal(await hasReleasableChanges((await history(breaking)).manifest), true); + const [proposal] = await (await history(breaking)).manifest.buildPullRequests(); + assert.equal(proposal.version.toString(), '0.1.0'); + } + + function existing(proposal) { + return { + number: 116, title: proposal.title.toString(), body: proposal.body.toString(), + headBranchName: proposal.headRefName, baseBranchName: 'main', + labels: ['autorelease: pending'], files: [], + }; + } + for (const alwaysUpdate of [false, true]) { + const {manifest, state} = await history(mixed, {'always-update': alwaysUpdate}); + const [proposal] = await manifest.buildPullRequests(); + state.open.push(existing(proposal)); + await manifest.createPullRequests(); + assert.equal(state.updates.length, alwaysUpdate ? 1 : 0, + 'unchanged notes must still refresh an eligible release branch'); + } + const pending = await history(mixed); + pending.state.merged.push({...existing(proposals[0]), sha: 'b'.repeat(40)}); + assert.equal(await hasReleasableChanges(pending.manifest), false, + 'finish the merged release before planning another release PR'); + const unavailable = await history(mixed); + unavailable.github.releaseIterator = async function* () { + throw new Error('release history unavailable'); + }; + await assert.rejects(hasReleasableChanges(unavailable.manifest), /release history unavailable/); + + console.log('release-trigger-harness: eligibility, mixed notes, pending releases, and branch refresh ok'); + +} diff --git a/tools/release-please-policy/validate-workflows.mjs b/tools/release-please-policy/validate-workflows.mjs index 2662afd..8dee78a 100644 --- a/tools/release-please-policy/validate-workflows.mjs +++ b/tools/release-please-policy/validate-workflows.mjs @@ -59,10 +59,6 @@ for (const file of workflowFiles) { assert.ok(!source.includes('pull_request_target:'), `${file} must not use pull_request_target`); assert.ok(!/curl[^\n]*\|\s*(?:bash|sh)/.test(source), `${file} must not execute a remote installer`); assert.ok(!source.includes('--jq --arg'), `${file} passes unsupported jq arguments to gh --jq`); - for (const match of source.matchAll(/\bnpm ci[^\n]*/g)) { - assert.ok(match[0].includes('--omit=dev'), `${file} installs dev-only release tooling`); - assert.ok(match[0].includes('--ignore-scripts'), `${file} enables dependency lifecycle scripts`); - } const workflow = parse(file); assert.deepEqual(workflow.permissions, {contents: 'read'}, `${file} must default to read-only contents`); for (const [jobName, job] of Object.entries(workflow.jobs)) { @@ -73,6 +69,21 @@ for (const file of workflowFiles) { } for (const [index, step] of (job.steps || []).entries()) { if (typeof step.run !== 'string') continue; + for (const match of step.run.matchAll(/\bnpm ci[^\n]*/g)) { + const readOnlyPlanner = file === '.github/workflows/release-please.yml' && + jobName === 'gate' && step.name === 'Install locked release policy dependencies'; + if (readOnlyPlanner) { + assert.equal(job.environment, undefined, 'eligibility must not access the protected environment'); + assert.deepEqual(job.permissions, { + actions: 'read', attestations: 'read', contents: 'read', 'pull-requests': 'read', + }); + assert.ok(!jobText.includes('secrets.') && !jobText.includes('actions/create-github-app-token'), + 'eligibility dependency installation must remain isolated from App credentials'); + } else { + assert.ok(match[0].includes('--omit=dev'), `${file}:${jobName} installs dev-only release tooling`); + } + assert.ok(match[0].includes('--ignore-scripts'), `${file} enables dependency lifecycle scripts`); + } assert.ok(!step.run.includes('${{ inputs.'), `${file}:${jobName}:step-${index + 1} embeds workflow-dispatch input in shell source`); const bashStep = step.shell === 'bash' || (!step.shell && String(job['runs-on']).startsWith('ubuntu')); @@ -135,6 +146,50 @@ assert.ok(rpTokenIdx < rpSteps.indexOf(rpPriorAfterToken) && rpSteps.indexOf(rpMissingDraft) < rpActionIdx, 'missing-draft create must run after the App-token prior-release recheck and before Release Please'); const rpAction = releasePlease.jobs['release-please'].steps.find(step => step.id === 'release'); +const gateSteps = releasePlease.jobs.gate.steps; +const rpTrigger = gateSteps.find(step => step.id === 'release-trigger'); +const rpNode = gateSteps.find(step => step.name === 'Install pinned Node for release eligibility'); +const rpInstall = gateSteps.find(step => step.name === 'Install locked release policy dependencies'); +const gateCheckout = gateSteps.find(step => step.uses?.startsWith('actions/checkout@')); +assert.equal(gateCheckout.with.ref, 'main'); +assert.equal(gateCheckout.with['fetch-depth'], 0); +assert.equal(rpNode.uses, `actions/setup-node@${tooling.actions.setupNode}`); +assert.equal(rpNode.with['node-version'], tooling.node); +assert.equal(rpInstall['working-directory'], 'tools/release-please-policy'); +assert.equal(rpInstall.run, 'npm ci --ignore-scripts --no-audit --no-fund'); +assert.equal(rpTrigger.env.GH_TOKEN, '${{ github.token }}'); +assert.ok(rpTrigger.run.includes('node tools/release-please-policy/harness.mjs --release-trigger')); +assert.ok(rpTrigger.run.includes('[ "$planned_main" = "$live_main" ]')); +assert.ok(rpTrigger.run.includes('echo "source_sha=$planned_main" >>"$GITHUB_OUTPUT"')); +assert.ok(gateSteps.indexOf(rpNode) < gateSteps.indexOf(rpInstall) && + gateSteps.indexOf(rpInstall) < gateSteps.indexOf(rpTrigger)); +for (const step of [rpNode, rpInstall, rpTrigger]) { + assert.equal(step.if, "steps.gate.outputs.ready == 'true'"); +} +assert.equal(releasePlease.jobs.gate.outputs.source_sha, '${{ steps.release-trigger.outputs.source_sha }}'); +assert.equal(releasePlease.jobs.gate.outputs.releasable, '${{ steps.release-trigger.outputs.releasable }}'); +assert.equal(rpRevalidate.env.ELIGIBILITY_SOURCE_SHA, '${{ needs.gate.outputs.source_sha }}'); +assert.ok(rpRevalidate.run.includes('[ "$live_main" = "$ELIGIBILITY_SOURCE_SHA" ]')); +assert.equal(rpAction.with['skip-github-pull-request'], true); +assert.equal(rpAction.with['skip-github-release'], undefined, + 'notes-only eligibility must not suppress tagging an already merged release'); +const rpPrAction = rpSteps.find(step => step.id === 'release-pr'); +assert.ok(rpSteps.indexOf(rpPrAction) > rpActionIdx); +assert.equal(rpPrAction.if, + "steps.missing-draft.outputs.created != 'true' && steps.release.outputs.releases_created != 'true' && needs.gate.outputs.releasable == 'true'"); +assert.equal(rpPrAction.uses, `googleapis/release-please-action@${tooling.releasePleaseAction.commit}`); +assert.deepEqual(rpPrAction.with, { + token: '${{ steps.app-token.outputs.token }}', + 'target-branch': 'main', + 'config-file': 'release-please-config.json', + 'manifest-file': '.release-please-manifest.json', + 'skip-github-release': true, +}); +assert.equal(releasePlease.jobs['release-please'].outputs.pr, '${{ steps.release-pr.outputs.pr }}'); +assert.equal(releasePlease.jobs['release-please'].outputs.prs_created, '${{ steps.release-pr.outputs.prs_created }}'); +const rpSummary = rpSteps.find(step => step.name === 'Validate and summarize result'); +assert.equal(rpSummary.env.PRS_CREATED, '${{ steps.release-pr.outputs.prs_created }}'); +assert.equal(rpSummary.env.PR, '${{ steps.release-pr.outputs.pr }}'); assert.equal(rpAction.if, "steps.missing-draft.outputs.created != 'true'", 'Release Please must not open a newer version while it just created the missing draft'); assert.equal(rpAction.uses, `googleapis/release-please-action@${tooling.releasePleaseAction.commit}`);