From 7443ca4587f9519c4dc5c3fcf91f9a653429d2af Mon Sep 17 00:00:00 2001 From: BurntToasters <61037367+BurntToasters@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:21:18 -0700 Subject: [PATCH] Add release draft commit safeguards Require release drafts to match the correct channel branch and its fetched origin tip. Set the draft target to that commit, reject mismatched drafts on creators and waiters, and add E2E coverage and contributor guidance. --- CONTRIBUTING.md | 4 +- package.json | 2 +- scripts/ensure-draft-release.cjs | 134 +++++++++- scripts/ensure-draft-release.e2e.test.cjs | 291 ++++++++++++++++++++++ scripts/ensure-draft-release.failures.md | 17 ++ 5 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 scripts/ensure-draft-release.e2e.test.cjs create mode 100644 scripts/ensure-draft-release.failures.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48cf2a9..b53d2e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,9 @@ This runs version sync, static checks, hygiene, analyze, format, unit tests, cov Before a stable cut, run the manual checklist in [docs/QA.md](docs/QA.md). -`release:prepare` intentionally does **not** run a clean-tree / branch guard; maintainers release from the branch they are on after `release:warn`. Keep the working tree intentional; do not assume an automated guard. +`release:prepare` intentionally does **not** run a clean-tree guard. Keep the working tree intentional; do not assume an automated guard. + +`release:draft` (Windows) and `release:wait-draft` (macOS/Linux) do guard the branch: stable versions must be on `main`, beta/alpha versions on `beta`, and local HEAD must equal the freshly fetched `origin/`. The Windows machine creates the draft with `target_commitish` set to that exact commit, so the tag lands on the branch tip at publish. macOS/Linux refuse a draft that targets a different commit than their checkout; set `FORCE_UPLOAD=1` to bypass that one check. `release:prepare` validates `CHANGELOG.md` against the package version. `release:draft` then copies it into the GitHub draft body. If the matching draft diff --git a/package.json b/package.json index c3c36df..b63daa6 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "test:coverage": "fvm flutter test --coverage", "coverage:check": "node scripts/check-coverage.js", "test:all": "node scripts/test-all.js && npm run test:scripts", - "test:scripts": "node --test scripts/github-cli.test.cjs scripts/post-release-assets.test.js scripts/release-notes.test.cjs scripts/semver-sort.test.js scripts/setup-flutter.test.js scripts/update-cacert.test.js scripts/bundle-linux-native-libs.test.js scripts/test-all.test.js scripts/sync-version.e2e.test.js scripts/verify-draft-release.e2e.test.cjs scripts/platform-release-smoke.e2e.test.js", + "test:scripts": "node --test scripts/github-cli.test.cjs scripts/post-release-assets.test.js scripts/release-notes.test.cjs scripts/semver-sort.test.js scripts/setup-flutter.test.js scripts/update-cacert.test.js scripts/bundle-linux-native-libs.test.js scripts/test-all.test.js scripts/sync-version.e2e.test.js scripts/verify-draft-release.e2e.test.cjs scripts/ensure-draft-release.e2e.test.cjs scripts/platform-release-smoke.e2e.test.js", "test:platform-release-smoke": "node --test scripts/platform-release-smoke.e2e.test.js", "test:release-assets": "npm run test:scripts", "check:version": "node scripts/check-version-sync.js", diff --git a/scripts/ensure-draft-release.cjs b/scripts/ensure-draft-release.cjs index cfde745..a3bed18 100644 --- a/scripts/ensure-draft-release.cjs +++ b/scripts/ensure-draft-release.cjs @@ -3,14 +3,25 @@ // (default) create-or-reuse the single draft. Run by the Windows machine only. // --wait poll until that draft exists; NEVER create. Run by mac/linux so // they only ever reuse the draft Windows created (no duplicates). +// The draft targets the exact commit at the tip of the channel branch (main for +// stable, beta for beta/alpha), and waiters refuse a draft built from another commit. -require('dotenv').config(); +const path = require('path'); +const { execFileSync } = require('child_process'); + +try { + require('dotenv').config(); +} catch { + // dotenv-cli usually loads .env already; the module itself is optional. +} const { assertGitHubCliAuthenticated, githubApi } = require('./github-cli.cjs'); const { assertValidReleaseNotes, readReleaseNotes, } = require('./release-notes.cjs'); +const REPOSITORY_ROOT = path.resolve(__dirname, '..'); +const RELEASE_REMOTE = 'origin'; const REPO_OWNER = 'BurntToasters'; const REPO_NAME = 'Dacx'; const GH_REQUEST_RETRIES = Number.parseInt(process.env.GH_REQUEST_RETRIES || '3', 10); @@ -34,6 +45,103 @@ const TAG_NAME = 'v' + VERSION; const IS_PRERELEASE = VERSION.includes('beta') || VERSION.includes('alpha'); const RELEASE_BODY = readReleaseNotes(); assertValidReleaseNotes(RELEASE_BODY, VERSION); +const RELEASE_BRANCH = IS_PRERELEASE ? 'beta' : 'main'; + +function git(args) { + return execFileSync('git', args, { + cwd: REPOSITORY_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +// Returns the SHA of the latest commit on the channel branch, and only when the +// local checkout is that branch and matches the remote tip exactly. +function resolveReleaseCommit() { + let branch; + try { + branch = git(['symbolic-ref', '--quiet', '--short', 'HEAD']); + } catch { + throw new Error( + 'Detached HEAD; check out ' + RELEASE_BRANCH + ' before releasing ' + VERSION + '.' + ); + } + if (branch !== RELEASE_BRANCH) { + throw new Error( + (IS_PRERELEASE ? 'Beta' : 'Stable') + + ' release ' + + VERSION + + ' must be built from ' + + RELEASE_BRANCH + + ', but the checkout is on ' + + branch + + '.' + ); + } + + const remoteRef = 'refs/remotes/' + RELEASE_REMOTE + '/' + RELEASE_BRANCH; + try { + git(['fetch', '--quiet', RELEASE_REMOTE, '+refs/heads/' + RELEASE_BRANCH + ':' + remoteRef]); + } catch (error) { + const detail = String(error.stderr || error.message || '').trim(); + throw new Error( + 'Could not fetch ' + RELEASE_REMOTE + '/' + RELEASE_BRANCH + (detail ? ': ' + detail : '.'), + { cause: error } + ); + } + + const head = git(['rev-parse', 'HEAD']); + const remoteHead = git(['rev-parse', remoteRef]); + if (!/^[0-9a-f]{40,64}$/i.test(head)) { + throw new Error('Could not resolve an exact release commit from git HEAD.'); + } + if (head !== remoteHead) { + throw new Error( + 'Local ' + + RELEASE_BRANCH + + ' is at ' + + head.slice(0, 12) + + ' but ' + + RELEASE_REMOTE + + '/' + + RELEASE_BRANCH + + ' is at ' + + remoteHead.slice(0, 12) + + '. Pull or push so the release targets the latest pushed commit.' + ); + } + return head; +} + +function isExplicitTruthy(value) { + return /^(1|true|yes|on)$/i.test(String(value || '').trim()); +} + +function assertReleaseTargetsCommit(release, commit) { + if (!release || !release.draft || release.target_commitish === commit) return release; + const target = release.target_commitish || 'an unknown commit'; + if (isExplicitTruthy(process.env.FORCE_UPLOAD)) { + console.warn( + 'WARNING: Draft release ' + + TAG_NAME + + ' targets ' + + target + + ', not checked-out commit ' + + commit + + '. FORCE_UPLOAD=1 bypassing commit check.' + ); + return release; + } + throw new Error( + 'Draft release ' + + TAG_NAME + + ' targets ' + + target + + ', not checked-out commit ' + + commit + + '. Delete or retarget the stale draft, or set FORCE_UPLOAD=1 to bypass.' + ); +} function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -118,11 +226,20 @@ async function findExistingRelease() { return draft || matching[0]; } -async function ensureDraftRelease() { - console.log('Ensuring draft release exists for ' + TAG_NAME + '...'); +async function ensureDraftRelease(commit) { + console.log( + 'Ensuring draft release exists for ' + + TAG_NAME + + ' targeting ' + + RELEASE_BRANCH + + '@' + + commit.slice(0, 12) + + '...' + ); const existing = await findExistingRelease(); if (existing) { + assertReleaseTargetsCommit(existing, commit); if (existing.draft && existing.body !== RELEASE_BODY) { console.log(' Draft exists but release notes differ; updating body...'); return await githubRequestWithRetry( @@ -155,6 +272,7 @@ async function ensureDraftRelease() { // Match electron-builder's createRelease() so it reuses this draft: // tag = "v" + version, name defaults to the version, draft:true. tag_name: TAG_NAME, + target_commitish: commit, name: VERSION, draft: true, prerelease: IS_PRERELEASE, @@ -171,14 +289,14 @@ async function ensureDraftRelease() { const afterRetry = await findExistingRelease(); if (afterRetry) { console.log(' Found existing draft after retry: id ' + afterRetry.id); - return afterRetry; + return assertReleaseTargetsCommit(afterRetry, commit); } } throw error; } } -async function waitForDraftRelease() { +async function waitForDraftRelease(commit) { const deadline = Date.now() + WAIT_TIMEOUT_MS; console.log( 'Waiting for draft release ' + @@ -191,6 +309,7 @@ async function waitForDraftRelease() { attempt += 1; const existing = await findExistingRelease(); if (existing) { + assertReleaseTargetsCommit(existing, commit); console.log( ' Found draft: ' + (existing.name || TAG_NAME) + @@ -225,12 +344,13 @@ async function waitForDraftRelease() { } async function main() { + const commit = resolveReleaseCommit(); assertGitHubCliAuthenticated(); if (WAIT_MODE) { - await waitForDraftRelease(); + await waitForDraftRelease(commit); } else { - await ensureDraftRelease(); + await ensureDraftRelease(commit); } } diff --git a/scripts/ensure-draft-release.e2e.test.cjs b/scripts/ensure-draft-release.e2e.test.cjs new file mode 100644 index 0000000..8423249 --- /dev/null +++ b/scripts/ensure-draft-release.e2e.test.cjs @@ -0,0 +1,291 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +// Runs the real ensure-draft-release.cjs inside a throwaway git repo that has a +// real bare "origin", with a stub `gh` on PATH that records GitHub API calls. +// Covers scripts/ensure-draft-release.failures.md. + +const root = path.dirname(path.dirname(__filename)); +const artifactDir = path.join(root, 'test-results', 'ensure-draft-release'); +const copiedScripts = ['ensure-draft-release.cjs', 'github-cli.cjs', 'release-notes.cjs']; +const report = []; + +const skip = process.platform === 'win32' ? 'stub gh is a POSIX shebang script' : false; + +const FAKE_GH = `#!/usr/bin/env node +const fs = require('fs'); +const statePath = process.env.FAKE_GH_STATE; +const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); +const args = process.argv.slice(2); +const save = () => fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); +if (args[0] === 'auth') process.exit(0); +const method = args[args.indexOf('--method') + 1]; +const endpoint = args[3]; +const body = args.includes('--input') ? JSON.parse(fs.readFileSync(0, 'utf8') || '{}') : null; +state.calls.push({ method, endpoint, body }); +let out; +if (method === 'GET' && endpoint.includes('/releases?')) { + out = state.releases; +} else if (method === 'POST' && endpoint.endsWith('/releases')) { + out = { id: state.releases.length + 1, assets: [], ...body }; + if (!('target_commitish' in body)) out.target_commitish = 'main'; + state.releases.push(out); +} else if (method === 'PATCH') { + const id = Number(endpoint.split('/').pop()); + out = state.releases.find((r) => r.id === id); + Object.assign(out, body); +} else { + save(); + process.stderr.write('stub gh: unhandled ' + method + ' ' + endpoint + ' (HTTP 404)'); + process.exit(1); +} +save(); +process.stdout.write(JSON.stringify(out)); +`; + +function sh(cwd, cmd, args) { + const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error(`${cmd} ${args.join(' ')} failed: ${result.stderr}`); + } + return result.stdout.trim(); +} + +function changelog(version) { + const beta = version.includes('beta'); + return ( + (beta ? '> [!NOTE]\n> This is a Beta build.\n\n' : '') + + `[Download](https://github.com/BurntToasters/Dacx/releases/download/v${version}/Dacx-macOS.dmg)\n\n` + + `## Changes in \`v${version}:\`\n- Fixture entry.\n` + ); +} + +// Builds origin (bare) with main and beta, and a clone checked out on `branch`. +function fixture({ version, branch }) { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'dacx-draft-target-')); + const origin = path.join(base, 'origin.git'); + const work = path.join(base, 'work'); + const bin = path.join(base, 'bin'); + fs.mkdirSync(path.join(work, 'scripts'), { recursive: true }); + fs.mkdirSync(bin); + sh(base, 'git', ['init', '--quiet', '--bare', origin]); + sh(work, 'git', ['init', '--quiet', '-b', 'main']); + sh(work, 'git', ['config', 'user.email', 'e2e@example.invalid']); + sh(work, 'git', ['config', 'user.name', 'e2e']); + sh(work, 'git', ['config', 'commit.gpgsign', 'false']); + sh(work, 'git', ['remote', 'add', 'origin', origin]); + for (const name of copiedScripts) { + fs.copyFileSync(path.join(root, 'scripts', name), path.join(work, 'scripts', name)); + } + fs.writeFileSync(path.join(work, 'package.json'), JSON.stringify({ version }) + '\n'); + fs.writeFileSync(path.join(work, 'CHANGELOG.md'), changelog(version)); + sh(work, 'git', ['add', '-A']); + sh(work, 'git', ['commit', '--quiet', '-m', 'main base']); + sh(work, 'git', ['push', '--quiet', 'origin', 'main']); + sh(work, 'git', ['checkout', '--quiet', '-b', 'beta']); + sh(work, 'git', ['commit', '--quiet', '--allow-empty', '-m', 'beta tip']); + sh(work, 'git', ['push', '--quiet', 'origin', 'beta']); + sh(work, 'git', ['checkout', '--quiet', branch]); + fs.writeFileSync(path.join(bin, 'gh'), FAKE_GH, { mode: 0o755 }); + const statePath = path.join(base, 'gh-state.json'); + fs.writeFileSync(statePath, JSON.stringify({ releases: [], calls: [] })); + return { base, work, bin, statePath, origin }; +} + +function run(fx, args = [], env = {}) { + const result = spawnSync(process.execPath, [path.join(fx.work, 'scripts', 'ensure-draft-release.cjs'), ...args], { + cwd: fx.work, + encoding: 'utf8', + env: { + ...process.env, + PATH: fx.bin + path.delimiter + process.env.PATH, + FAKE_GH_STATE: fx.statePath, + FORCE_UPLOAD: '', + GH_REQUEST_RETRIES: '1', + RELEASE_DRAFT_WAIT_TIMEOUT_MS: '0', + RELEASE_DRAFT_WAIT_POLL_MS: '1', + ...env, + }, + }); + return { ...result, state: JSON.parse(fs.readFileSync(fx.statePath, 'utf8')) }; +} + +function seedDraft(fx, version, target) { + const state = JSON.parse(fs.readFileSync(fx.statePath, 'utf8')); + state.releases.push({ + id: 1, + tag_name: 'v' + version, + name: version, + draft: true, + prerelease: version.includes('beta'), + target_commitish: target, + body: 'stale', + assets: [], + }); + fs.writeFileSync(fx.statePath, JSON.stringify(state)); +} + +function record(name, fx, result, expected) { + report.push({ + name, + expected, + status: result.status, + head: sh(fx.work, 'git', ['rev-parse', 'HEAD']), + stdout: result.stdout, + stderr: result.stderr, + githubCalls: result.state.calls, + releases: result.state.releases, + }); +} + +const writes = (state) => state.calls.filter((c) => c.method !== 'GET'); + +test.after(() => { + if (!report.length) return; + fs.rmSync(artifactDir, { recursive: true, force: true }); + fs.mkdirSync(artifactDir, { recursive: true }); + fs.writeFileSync(path.join(artifactDir, 'report.json'), JSON.stringify(report, null, 2) + '\n'); +}); + +test('stable draft from main targets the main tip commit', { skip }, () => { + const fx = fixture({ version: '1.0.0', branch: 'main' }); + const mainTip = sh(fx.work, 'git', ['rev-parse', 'origin/main']); + const result = run(fx); + record('stable-main-creates-draft', fx, result, 'draft targets main tip'); + assert.equal(result.status, 0, result.stderr); + const [post] = writes(result.state); + assert.equal(post.method, 'POST'); + assert.equal(post.body.target_commitish, mainTip); + assert.equal(post.body.prerelease, false); + assert.equal(post.body.draft, true); +}); + +test('beta draft from beta targets the beta tip commit, not main', { skip }, () => { + const fx = fixture({ version: '1.0.0-beta.1', branch: 'beta' }); + const betaTip = sh(fx.work, 'git', ['rev-parse', 'origin/beta']); + const mainTip = sh(fx.work, 'git', ['rev-parse', 'origin/main']); + const result = run(fx); + record('beta-beta-creates-draft', fx, result, 'draft targets beta tip'); + assert.equal(result.status, 0, result.stderr); + const [post] = writes(result.state); + assert.equal(post.body.target_commitish, betaTip); + assert.notEqual(post.body.target_commitish, mainTip); + assert.equal(post.body.prerelease, true); +}); + +test('stable version on beta branch is refused before any GitHub call', { skip }, () => { + const fx = fixture({ version: '1.0.0', branch: 'beta' }); + const result = run(fx); + record('stable-on-beta-refused', fx, result, 'refused, no GitHub calls'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /must be built from main, but the checkout is on beta/); + assert.equal(result.state.calls.length, 0); +}); + +test('beta version on main branch is refused', { skip }, () => { + const fx = fixture({ version: '1.0.0-beta.1', branch: 'main' }); + const result = run(fx); + record('beta-on-main-refused', fx, result, 'refused, no GitHub calls'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /must be built from beta, but the checkout is on main/); + assert.equal(result.state.calls.length, 0); +}); + +test('detached HEAD is refused', { skip }, () => { + const fx = fixture({ version: '1.0.0', branch: 'main' }); + sh(fx.work, 'git', ['checkout', '--quiet', '--detach']); + const result = run(fx); + record('detached-head-refused', fx, result, 'refused, no GitHub calls'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Detached HEAD/); + assert.equal(result.state.calls.length, 0); +}); + +test('unpushed local commit is refused', { skip }, () => { + const fx = fixture({ version: '1.0.0', branch: 'main' }); + sh(fx.work, 'git', ['commit', '--quiet', '--allow-empty', '-m', 'local only']); + const result = run(fx); + record('unpushed-commit-refused', fx, result, 'refused, no GitHub calls'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Pull or push so the release targets the latest pushed commit/); + assert.equal(result.state.calls.length, 0); +}); + +test('checkout behind origin is refused so the draft cannot target an old commit', { skip }, () => { + const fx = fixture({ version: '1.0.0', branch: 'main' }); + const other = path.join(fx.base, 'other'); + sh(fx.base, 'git', ['clone', '--quiet', '-b', 'main', fx.origin, other]); + sh(other, 'git', ['-c', 'user.email=e@x.invalid', '-c', 'user.name=x', '-c', 'commit.gpgsign=false', + 'commit', '--quiet', '--allow-empty', '-m', 'newer upstream']); + sh(other, 'git', ['push', '--quiet', 'origin', 'main']); + const result = run(fx); + record('behind-origin-refused', fx, result, 'refused, no GitHub calls'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /but origin\/main is at/); + assert.equal(result.state.calls.length, 0); +}); + +test('missing origin branch fails closed', { skip }, () => { + const fx = fixture({ version: '1.0.0', branch: 'main' }); + sh(fx.work, 'git', ['push', '--quiet', 'origin', '--delete', 'main']); + const result = run(fx); + record('missing-origin-branch-refused', fx, result, 'refused, no GitHub calls'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Could not fetch origin\/main/); + assert.equal(result.state.calls.length, 0); +}); + +test('creator refuses to reuse a draft that targets a stale commit', { skip }, () => { + const fx = fixture({ version: '1.0.0', branch: 'main' }); + seedDraft(fx, '1.0.0', '0'.repeat(40)); + const result = run(fx); + record('creator-stale-draft-refused', fx, result, 'refused, draft untouched'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Delete or retarget the stale draft/); + assert.equal(writes(result.state).length, 0); +}); + +test('creator refreshes notes on a matching draft without changing its target', { skip }, () => { + const fx = fixture({ version: '1.0.0', branch: 'main' }); + const head = sh(fx.work, 'git', ['rev-parse', 'HEAD']); + seedDraft(fx, '1.0.0', head); + const result = run(fx); + record('creator-refresh-keeps-target', fx, result, 'PATCH body only, target kept'); + assert.equal(result.status, 0, result.stderr); + const [patch] = writes(result.state); + assert.equal(patch.method, 'PATCH'); + assert.equal('target_commitish' in patch.body, false); + assert.equal(result.state.releases[0].target_commitish, head); +}); + +test('waiter proceeds when the draft targets its checked-out commit', { skip }, () => { + const fx = fixture({ version: '1.0.0-beta.1', branch: 'beta' }); + seedDraft(fx, '1.0.0-beta.1', sh(fx.work, 'git', ['rev-parse', 'HEAD'])); + const result = run(fx, ['--wait']); + record('waiter-matching-draft', fx, result, 'proceeds, no writes'); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Proceeding/); + assert.equal(writes(result.state).length, 0); +}); + +test('waiter refuses a draft built from another commit', { skip }, () => { + const fx = fixture({ version: '1.0.0-beta.1', branch: 'beta' }); + seedDraft(fx, '1.0.0-beta.1', sh(fx.work, 'git', ['rev-parse', 'origin/main'])); + const result = run(fx, ['--wait']); + record('waiter-mismatched-draft-refused', fx, result, 'refused'); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /not checked-out commit/); +}); + +test('FORCE_UPLOAD=1 bypasses a target mismatch with a visible warning', { skip }, () => { + const fx = fixture({ version: '1.0.0-beta.1', branch: 'beta' }); + seedDraft(fx, '1.0.0-beta.1', '0'.repeat(40)); + const result = run(fx, ['--wait'], { FORCE_UPLOAD: '1' }); + record('force-upload-bypass-warns', fx, result, 'proceeds with warning'); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /WARNING: .*FORCE_UPLOAD=1 bypassing commit check/); +}); diff --git a/scripts/ensure-draft-release.failures.md b/scripts/ensure-draft-release.failures.md new file mode 100644 index 0000000..0241266 --- /dev/null +++ b/scripts/ensure-draft-release.failures.md @@ -0,0 +1,17 @@ +# Draft Release Target Failure Inventory + +`npm run release:draft` (Windows, creator) and `npm run release:wait-draft` +(macOS/Linux, waiters) must fail safely for these cases: + +- Stable version run from any branch other than `main`. +- Beta/alpha version run from any branch other than `beta`. +- Detached HEAD, so there is no branch to release from. +- Local HEAD is behind `origin/`, so the draft would not target the latest commit. +- Local HEAD has commits not pushed to `origin/`, so GitHub cannot target them. +- `origin/` cannot be fetched, so "latest" cannot be proven. +- Draft is created without `target_commitish`, so GitHub tags the default branch on publish. +- Creator finds an existing draft that targets a different commit and silently reuses it. +- Waiter finds a draft that targets a different commit and uploads assets built from other sources. +- `FORCE_UPLOAD=1` bypass is used without a visible warning. +- Release notes refresh (PATCH) drops or changes the draft's target commit. +- A failed branch or target check still creates or modifies a GitHub release.