diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000..d5459d7 --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,193 @@ +name: Create release tag + +# This privileged workflow runs from the trusted default-branch copy. It never +# checks out or executes pull-request code: package versions are read as data +# through the GitHub API from the reviewed base and integrated merge commits. +on: + pull_request_target: + types: [closed] + branches: [main] + +permissions: + contents: write + actions: write + +concurrency: + group: release-tag-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + tag-and-dispatch: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Create annotated version tag and dispatch Release + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + + if (!pr.merged || pr.base.ref !== 'main') { + throw new Error('release tagging requires a merged pull request into main'); + } + + const mergeSha = pr.merge_commit_sha; + if (!/^[0-9a-f]{40}$/.test(mergeSha || '')) { + throw new Error(`invalid merge commit SHA: ${mergeSha || ''}`); + } + + const changedFiles = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + if (!changedFiles.some((file) => file.filename === 'package.json')) { + core.notice('pull request did not change package.json; no release tag required'); + return; + } + + function parseStableVersion(value, ref) { + const version = typeof value === 'string' ? value : ''; + // Release tags are intentionally limited to stable SemVer. A + // prerelease/build requires a separately reviewed policy change. + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); + if (!match) { + throw new Error(`package.json at ${ref} has invalid stable SemVer: ${version || ''}`); + } + return { + version, + tuple: match.slice(1).map((part) => BigInt(part)), + }; + } + + async function readPackage(ref) { + const response = await github.rest.repos.getContent({ + owner, + repo, + path: 'package.json', + ref, + }); + const file = response.data; + if (Array.isArray(file) || file.type !== 'file' || !file.content) { + throw new Error(`package.json at ${ref} is not a readable file`); + } + let parsed; + try { + parsed = JSON.parse(Buffer.from(file.content, file.encoding || 'base64').toString('utf8')); + } catch (error) { + throw new Error(`package.json at ${ref} is invalid JSON: ${error.message}`); + } + return parseStableVersion(parsed.version, ref); + } + + function compareTuple(left, right) { + for (let index = 0; index < left.length; index += 1) { + if (left[index] > right[index]) return 1; + if (left[index] < right[index]) return -1; + } + return 0; + } + + const base = await readPackage(pr.base.sha); + const merged = await readPackage(mergeSha); + if (base.version === merged.version) { + core.notice(`package version unchanged at ${merged.version}; no release tag required`); + return; + } + if (compareTuple(merged.tuple, base.tuple) <= 0) { + throw new Error(`package version must increase: ${base.version} -> ${merged.version}`); + } + + const tag = `v${merged.version}`; + const message = `agentsmd ${tag}`; + + async function getExistingRef() { + try { + const response = await github.rest.git.getRef({ + owner, + repo, + ref: `tags/${tag}`, + }); + return response.data; + } catch (error) { + if (error.status === 404) return null; + throw error; + } + } + + async function verifyExistingTag(existingRef) { + if (existingRef.object.type !== 'tag') { + throw new Error(`${tag} exists but is not an annotated tag`); + } + const response = await github.rest.git.getTag({ + owner, + repo, + tag_sha: existingRef.object.sha, + }); + const tagObject = response.data; + if (tagObject.tag !== tag || + tagObject.message !== message || + tagObject.object.type !== 'commit' || + tagObject.object.sha !== mergeSha) { + throw new Error(`${tag} exists but does not match the intended release commit and annotation`); + } + return tagObject; + } + + let existingRef = await getExistingRef(); + if (existingRef) { + await verifyExistingTag(existingRef); + core.notice(`${tag} already exists with the intended annotation and commit`); + } else { + const created = await github.rest.git.createTag({ + owner, + repo, + tag, + message, + object: mergeSha, + type: 'commit', + }); + try { + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${tag}`, + sha: created.data.sha, + }); + } catch (error) { + // A concurrent retry may have won the ref race. Accept it only + // after applying the same exact annotation/commit checks. + if (error.status !== 422) throw error; + } + existingRef = await getExistingRef(); + if (!existingRef) { + throw new Error(`${tag} reference was not created`); + } + await verifyExistingTag(existingRef); + core.notice(`created annotated tag ${tag} at ${mergeSha}`); + } + + const runs = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'release.yml', + per_page: 100, + }); + const prior = runs.data.workflow_runs.find( + (run) => run.head_branch === tag && run.head_sha === mergeSha, + ); + if (prior) { + core.notice(`Release already has run ${prior.id} for ${tag} at ${mergeSha}`); + return; + } + + await github.rest.actions.createWorkflowDispatch({ + owner, + repo, + workflow_id: 'release.yml', + ref: tag, + }); + core.notice(`dispatched Release for ${tag} at ${mergeSha}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d5f998..7e52bd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,10 @@ on: push: tags: - 'v*' + # Tags created with GITHUB_TOKEN do not recursively trigger push workflows. + # release-tag.yml therefore dispatches this workflow with the annotated tag + # as the ref; the existing tag/package assertions still gate publication. + workflow_dispatch: permissions: contents: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a53312..e477b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ Release history for **agentsmd** (the Codex coding-spec enforcement plugin). The spec's own rule-level history lives in `spec/AGENTS-CHANGELOG.md`. +## Unreleased + +- Added a least-privilege post-merge release handoff: when a pull request into + `main` raises the stable `package.json` version, the trusted default-branch + workflow creates or verifies an annotated `v` tag at the exact + integrated commit, then dispatches the existing Release workflow with that + tag as its ref. +- The tag workflow reads reviewed repository files only through the GitHub API + and never checks out or executes pull-request code while holding + `contents:write` and `actions:write`. Unchanged or decreasing versions are + non-publishing paths; conflicting existing tags fail without rewriting them. +- The Release workflow retains direct `v*` tag-push compatibility and now also + accepts an explicit `workflow_dispatch`, preserving all package-version, + full-CI, artifact, provenance, signature, registry-byte, and marketplace + gates. + ## v5.1.1 — 2026-07-30 — marketplace skill-count gate (patch) - Fixed the post-publish marketplace E2E so its packaged-skill assertion derives diff --git a/automation/release-readiness.md b/automation/release-readiness.md index b0da47c..9f086db 100644 --- a/automation/release-readiness.md +++ b/automation/release-readiness.md @@ -29,5 +29,27 @@ stays explicit. The result is readiness report-only. Even a green packet does not execute a ship operation. Ship actions remain behind the current task's AUTH boundary. +## Authorized merge handoff + +Once an authorized release task raises the stable `package.json` version and its +pull request is merged into `main`, `.github/workflows/release-tag.yml` performs +the repository handoff without executing pull-request code: + +1. Read `package.json` from the reviewed base commit and exact integrated commit + through the GitHub API. +2. Require a monotonic stable SemVer increase. An unchanged version is a no-op; + a decrease, prerelease, build suffix, malformed file, or invalid merge SHA + fails before creating a tag. +3. Create a Git tag object followed by `refs/tags/v`, producing an + annotated tag at the integrated commit. An existing ref is accepted only + when its annotation and peeled commit match exactly; it is never rewritten. +4. Dispatch `.github/workflows/release.yml` with the tag as `ref`. A matching + existing Release run makes a retry a no-op. + +The handoff has only `contents:write` and `actions:write`. The Release workflow +retains direct annotated-tag push compatibility and keeps the full CI, package, +provenance, signature, registry-byte, and marketplace gates. Revert the workflow +PR to return to manual tag creation. + Remove only task-owned package output and unpinned inactive worktree residue. Never automatically remove pinned, active, or permanent worktrees. diff --git a/scripts/tests/workflow-static.test.js b/scripts/tests/workflow-static.test.js index e3cb6ae..1dea7e3 100644 --- a/scripts/tests/workflow-static.test.js +++ b/scripts/tests/workflow-static.test.js @@ -7,17 +7,38 @@ const path = require('path'); const ROOT = path.resolve(__dirname, '..', '..'); const read = (relative) => fs.readFileSync(path.join(ROOT, relative), 'utf8'); +const TESTS = []; let PASS = 0; let FAIL = 0; function test(name, fn) { - try { - fn(); - PASS += 1; - console.log(` ok ${name}`); - } catch (error) { - FAIL += 1; - console.log(` FAIL ${name}\n ${error.message}`); + TESTS.push({ name, fn }); +} + +function readGithubScript(relative) { + const source = read(relative); + const marker = ' script: |\n'; + const index = source.indexOf(marker); + assert(index >= 0, `missing github-script block in ${relative}`); + return source + .slice(index + marker.length) + .split('\n') + .map((line) => line.startsWith(' ') ? line.slice(12) : line) + .join('\n'); +} + +async function run() { + for (const { name, fn } of TESTS) { + try { + await fn(); + PASS += 1; + console.log(` ok ${name}`); + } catch (error) { + FAIL += 1; + console.log(` FAIL ${name}\n ${error.message}`); + } } + console.log(`\nRESULT: ${PASS} passed, ${FAIL} failed`); + process.exitCode = FAIL === 0 ? 0 : 1; } test('all four distributed recipes exist and preserve authorization/worktree boundaries', () => { @@ -103,5 +124,224 @@ test('Codex review prompt treats repository and PR text as untrusted review inpu assert.match(prompt, /actionable/i); }); -console.log(`\nRESULT: ${PASS} passed, ${FAIL} failed`); -process.exit(FAIL === 0 ? 0 : 1); +test('Release retains tag-push compatibility and accepts an explicit tag-ref dispatch', () => { + const source = read('.github/workflows/release.yml'); + assert.match(source, /^ push:\s*$/m); + assert.match(source, /^\s+tags:\s*\n\s+- 'v\*'/m); + assert.match(source, /^ workflow_dispatch:\s*$/m); + assert.match(source, /Assert tag matches package version/); + assert.match(source, /test "\$TAG" = "v\$VER"/); +}); + +test('merged version PR automation creates a verified annotated tag and dispatches Release once', () => { + const relative = '.github/workflows/release-tag.yml'; + assert(fs.existsSync(path.join(ROOT, relative)), `missing ${relative}`); + const source = read(relative); + + assert.match(source, /^ pull_request_target:\s*$/m); + assert.match(source, /^\s+types:\s*\[closed\]\s*$/m); + assert.match(source, /^\s+branches:\s*\[main\]\s*$/m); + assert.match(source, /github\.event\.pull_request\.merged == true/); + assert.match(source, /permissions:\s*\n\s+contents:\s*write\s*\n\s+actions:\s*write/); + assert.doesNotMatch(source, /\bpull-requests:\s*write\b|\bpackages:\s*write\b|\bid-token:\s*write\b/); + assert.match(source, /actions\/github-script@[0-9a-f]{40}/); + assert.doesNotMatch(source, /actions\/checkout@|\bnpm (?:ci|install|test)\b|\bgit (?:checkout|pull|switch)\b/); + + assert.match(source, /path:\s*'package\.json'/); + assert.match(source, /github\.rest\.pulls\.listFiles/); + assert.match(source, /file\.filename === 'package\.json'/); + assert.match(source, /pr\.base\.sha/); + assert.match(source, /pr\.merge_commit_sha/); + assert.match(source, /stable SemVer/); + assert.match(source, /BigInt/); + assert.match(source, /base\.version === merged\.version/); + assert.match(source, /merged\.tuple.*base\.tuple/s); + + assert.match(source, /github\.rest\.git\.createTag/); + assert.match(source, /github\.rest\.git\.createRef/); + assert.match(source, /ref:\s*`refs\/tags\/\$\{tag\}`/); + assert.match(source, /github\.rest\.git\.getRef/); + assert.match(source, /github\.rest\.git\.getTag/); + assert.match(source, /existingRef\.object\.type !== 'tag'/); + assert.match(source, /tagObject\.object\.sha !== mergeSha/); + assert.match(source, /tagObject\.message !== message/); + + assert.match(source, /github\.rest\.actions\.listWorkflowRuns/); + assert.match(source, /workflow_id:\s*'release\.yml'/); + assert.match(source, /run\.head_branch === tag/); + assert.match(source, /github\.rest\.actions\.createWorkflowDispatch/); + assert.match(source, /ref:\s*tag/); + assert.match(source, /concurrency:\s*\n\s+group:/); + assert.match(source, /cancel-in-progress:\s*false/); +}); + +test('release tag script enforces no-op, monotonic version, tag identity, and single dispatch paths', async () => { + const source = readGithubScript('.github/workflows/release-tag.yml'); + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const execute = new AsyncFunction('github', 'context', 'core', source); + const mergeSha = 'a'.repeat(40); + const baseSha = '1'.repeat(40); + const context = { + repo: { owner: 'sdsrss', repo: 'agentsmd' }, + payload: { + pull_request: { + number: 42, + merged: true, + base: { ref: 'main', sha: baseSha }, + merge_commit_sha: mergeSha, + }, + }, + }; + + function packageFile(version) { + return { + data: { + type: 'file', + encoding: 'base64', + content: Buffer.from(JSON.stringify({ version })).toString('base64'), + }, + }; + } + + function harness({ + baseVersion = '5.1.1', + mergedVersion = '5.2.0', + changedFiles = [{ filename: 'package.json' }], + existingRef = null, + existingTag = null, + priorRuns = [], + } = {}) { + const calls = { + createTag: [], + createRef: [], + dispatch: [], + notices: [], + }; + let ref = existingRef; + const github = { + paginate: async () => changedFiles, + rest: { + pulls: { + listFiles: async () => { + throw new Error('listFiles must be called through github.paginate'); + }, + }, + repos: { + getContent: async ({ ref: requestedRef }) => + packageFile(requestedRef === baseSha ? baseVersion : mergedVersion), + }, + git: { + getRef: async () => { + if (!ref) throw Object.assign(new Error('not found'), { status: 404 }); + return { data: ref }; + }, + getTag: async () => ({ data: existingTag || { + tag: `v${mergedVersion}`, + message: `agentsmd v${mergedVersion}`, + object: { type: 'commit', sha: mergeSha }, + } }), + createTag: async (input) => { + calls.createTag.push(input); + return { data: { sha: 'b'.repeat(40) } }; + }, + createRef: async (input) => { + calls.createRef.push(input); + ref = { object: { type: 'tag', sha: input.sha } }; + return { data: ref }; + }, + }, + actions: { + listWorkflowRuns: async () => ({ data: { workflow_runs: priorRuns } }), + createWorkflowDispatch: async (input) => { + calls.dispatch.push(input); + }, + }, + }, + }; + const core = { + notice: (message) => calls.notices.push(message), + }; + return { github, core, calls }; + } + + { + const { github, core, calls } = harness({ changedFiles: [] }); + await execute(github, context, core); + assert.strictEqual(calls.createTag.length, 0); + assert.strictEqual(calls.createRef.length, 0); + assert.strictEqual(calls.dispatch.length, 0); + assert(calls.notices.some((message) => /did not change package\.json/.test(message))); + } + + { + const { github, core, calls } = harness({ mergedVersion: '5.1.1' }); + await execute(github, context, core); + assert.strictEqual(calls.createTag.length, 0); + assert.strictEqual(calls.createRef.length, 0); + assert.strictEqual(calls.dispatch.length, 0); + assert(calls.notices.some((message) => /version unchanged/.test(message))); + } + + { + const { github, core, calls } = harness(); + await execute(github, context, core); + assert.strictEqual(calls.createTag.length, 1); + assert.deepStrictEqual( + { + tag: calls.createTag[0].tag, + message: calls.createTag[0].message, + object: calls.createTag[0].object, + type: calls.createTag[0].type, + }, + { + tag: 'v5.2.0', + message: 'agentsmd v5.2.0', + object: mergeSha, + type: 'commit', + }, + ); + assert.strictEqual(calls.createRef.length, 1); + assert.strictEqual(calls.createRef[0].ref, 'refs/tags/v5.2.0'); + assert.strictEqual(calls.dispatch.length, 1); + assert.strictEqual(calls.dispatch[0].workflow_id, 'release.yml'); + assert.strictEqual(calls.dispatch[0].ref, 'v5.2.0'); + } + + for (const mergedVersion of ['5.0.9', '5.2.0-rc.1', '5.2.0+build.1']) { + const { github, core, calls } = harness({ mergedVersion }); + await assert.rejects(() => execute(github, context, core)); + assert.strictEqual(calls.createTag.length, 0); + assert.strictEqual(calls.createRef.length, 0); + assert.strictEqual(calls.dispatch.length, 0); + } + + { + const { github, core, calls } = harness({ + existingRef: { object: { type: 'commit', sha: mergeSha } }, + }); + await assert.rejects( + () => execute(github, context, core), + /exists but is not an annotated tag/, + ); + assert.strictEqual(calls.createTag.length, 0); + assert.strictEqual(calls.dispatch.length, 0); + } + + { + const { github, core, calls } = harness({ + existingRef: { object: { type: 'tag', sha: 'b'.repeat(40) } }, + priorRuns: [{ + id: 123, + head_branch: 'v5.2.0', + head_sha: mergeSha, + }], + }); + await execute(github, context, core); + assert.strictEqual(calls.createTag.length, 0); + assert.strictEqual(calls.createRef.length, 0); + assert.strictEqual(calls.dispatch.length, 0); + assert(calls.notices.some((message) => /already has run 123/.test(message))); + } +}); + +run();