diff --git a/.github/scripts/record-linear-release.cjs b/.github/scripts/record-linear-release.cjs new file mode 100644 index 00000000..a5f6832d --- /dev/null +++ b/.github/scripts/record-linear-release.cjs @@ -0,0 +1,178 @@ +// Records a deploy or a release as a Linear release. Run it from actions/github-script: +// +// script: return require('./.github/scripts/record-linear-release.cjs')({ github, context, core }) +// +// Environment: +// LINEAR_ACCESS_KEY The pipeline's access key. Without one, nothing is recorded. +// LINEAR_RELEASE_MODE "live" records the release. Anything else only logs what would be sent. +// RELEASE_SHA The commit that was deployed or tagged. +// RELEASE_VERSION The release tag, or for a deploy its short SHA (the default). +// RELEASE_BRANCH The branch PRs merge into. Defaults to main. +// RELEASE_NOTES "prs" (the default) lists the PRs. "github-release" copies the body of +// the GitHub release tagged RELEASE_VERSION. +// +// Output: release-url, the release's page in Linear. It stays empty when the release is only logged. +'use strict'; + +const LINEAR_API = 'https://api.linear.app/graphql'; +const MAX_BASE_CANDIDATES = 20; + +// On GitHub, "#12" is a PR or issue of the same repo. Linear matches a bare "#12" against every +// repo it knows, so it can link a PR from another repo. Every reference is therefore written out +// as a full link, except in code (fenced or inline), existing links and URLs. GitHub redirects +// /pull/N to /issues/N when N is an issue. +function linkReferences(text, owner, repo) { + const untouched = /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\)|<[^>\n]*>|https?:\/\/[^\s)]+)/; + return text + .split(untouched) + .map((part, i) => + i % 2 + ? part + : part + .replace( + /(^|[^\w/#&])([\w.-]+)\/([\w.-]+)#(\d+)\b/g, + (_, pre, o, r, n) => `${pre}[${o}/${r}#${n}](https://github.com/${o}/${r}/pull/${n})`, + ) + .replace( + /(^|[^\w/#&[]|\bPR ?)#(\d+)\b/g, + (_, pre, n) => `${pre}[#${n}](https://github.com/${owner}/${repo}/pull/${n})`, + ), + ) + .join(''); +} + +function prNotes(prs, owner, repo) { + const link = (n) => `[#${n}](https://github.com/${owner}/${repo}/pull/${n})`; + const dependabot = prs.filter((pr) => pr.user.login === 'dependabot[bot]'); + const lines = prs + .filter((pr) => !dependabot.includes(pr)) + .map((pr) => `- ${link(pr.number)} ${linkReferences(pr.title, owner, repo)} · ${pr.user.login}`); + if (dependabot.length) { + const s = dependabot.length > 1 ? 's' : ''; + lines.push(`- ${dependabot.length} Dependabot update${s}: ${dependabot.map((pr) => link(pr.number)).join(', ')}`); + } + return lines.join('\n'); +} + +// The PRs merged into `branch` since the newest earlier release in this pipeline. +async function pullRequestsSince({ github, owner, repo, branch, head, version, releases }) { + const compare = async (base, target, page = 1, perPage = 1) => + (await github.rest.repos.compareCommitsWithBasehead({ owner, repo, basehead: `${base}...${target}`, page, per_page: perPage })) + .data; + + // Testing can be deployed from a branch. Such a deploy gets no PRs; they count once they reach the branch. + if (!['ahead', 'identical'].includes((await compare(head, branch)).status)) { + return { prs: [], reason: `${head.slice(0, 7)} is not on ${branch}` }; + } + + // Sorted by completion time, since an older release whose notes were edited can be listed first. + // A release that is not on the branch is a branch deploy and is never a base. This release itself + // (a re-run) is skipped, but another version at the same commit is a base, and leaves no new PRs. + const candidates = releases + .filter((r) => r.commitSha && r.version !== version) + .sort((a, b) => Date.parse(b.completedAt || b.createdAt) - Date.parse(a.completedAt || a.createdAt)) + .slice(0, MAX_BASE_CANDIDATES); + let base; + for (const release of candidates) { + const { status } = await compare(release.commitSha, head); + if (status === 'diverged') continue; + // "behind": the newest release is ahead of this commit, so this deploy is a rollback. + if (status === 'behind') return { prs: [], reason: `rollback: ${release.version} is newer` }; + base = release; + break; + } + if (!base) return { prs: [], reason: 'no earlier release on the branch' }; + + const commits = []; + for (let page = 1; ; page++) { + const data = await compare(base.commitSha, head, page, 100); + commits.push(...data.commits); + if (data.commits.length < 100 || commits.length >= data.total_commits) break; + } + + // GitHub knows each commit's PR, whatever the merge method. + const prs = new Map(); + for (const { sha } of commits) { + const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner, repo, commit_sha: sha }); + for (const pr of data) { + if (pr.merged_at && pr.base.ref === branch && !prs.has(pr.number)) prs.set(pr.number, pr); + } + } + return { prs: [...prs.values()], base }; +} + +module.exports = async ({ github, context, core }) => { + const key = process.env.LINEAR_ACCESS_KEY; + const live = process.env.LINEAR_RELEASE_MODE === 'live'; + if (!key) { + if (live) throw new Error('LINEAR_RELEASE_MODE is live, but this pipeline has no Linear access key'); + core.notice('No Linear access key for this pipeline, so the release is not recorded.'); + return; + } + const required = (name) => { + if (!process.env[name]) throw new Error(`${name} is not set`); + return process.env[name]; + }; + const head = required('RELEASE_SHA'); + const version = process.env.RELEASE_VERSION || head.slice(0, 7); + const branch = process.env.RELEASE_BRANCH || 'main'; + const { owner, repo } = context.repo; + + const linear = async (query, variables) => { + const res = await fetch(LINEAR_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: key }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(30_000), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || body.errors) throw new Error(`Linear API ${res.status}: ${JSON.stringify(body.errors || body)}`); + return body.data; + }; + + const { recentReleasesByAccessKey: releases } = await linear( + 'query { recentReleasesByAccessKey(limit: 50) { commitSha version completedAt createdAt } }', + ); + const { prs, base, reason } = await pullRequestsSince({ github, owner, repo, branch, head, version, releases }); + + const links = [{ label: 'GitHub run', url: `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}` }]; + let notes; + if (process.env.RELEASE_NOTES === 'github-release') { + const { data } = await github.rest.repos.getReleaseByTag({ owner, repo, tag: version }); + notes = linkReferences((data.body || '').replace(/\r\n/g, '\n').trim(), owner, repo); + links.unshift({ label: 'GitHub release', url: data.html_url }); + } else { + notes = prNotes(prs, owner, repo); + } + + const input = { + name: version, + version, + commitSha: head, + repository: { owner, name: repo, provider: 'github', url: `https://github.com/${owner}/${repo}` }, + pullRequestReferences: prs.map((pr) => ({ repositoryOwner: owner, repositoryName: repo, number: pr.number })), + links, + ...(notes ? { releaseNotes: { title: version, content: notes } } : {}), + }; + + const since = base ? `since ${base.version}` : `none (${reason})`; + core.info(`PRs ${since}: ${prs.map((pr) => `#${pr.number}`).join(', ') || '-'}`); + if (!live) { + core.notice(`Log-only: ${version} would be recorded in Linear with ${prs.length} PRs. Set LINEAR_RELEASE_MODE=live to record it.`); + core.info(JSON.stringify(input, null, 2)); + return; + } + + const { releaseSyncByAccessKey: result } = await linear( + 'mutation ($input: ReleaseSyncInputBase!) { releaseSyncByAccessKey(input: $input) { success release { url } } }', + { input }, + ); + // Linear can report a failed sync with HTTP 200. + if (!result.success || !result.release) throw new Error(`Linear did not record ${version}: ${JSON.stringify(result)}`); + core.setOutput('release-url', result.release.url); + core.notice(`Recorded ${version} in Linear: ${result.release.url}`); +}; + +module.exports.linkReferences = linkReferences; +module.exports.prNotes = prNotes; +module.exports.pullRequestsSince = pullRequestsSince; diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 223ef34a..ecc2f708 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -93,7 +93,8 @@ jobs: # re-posts without publishing again. notify: name: Notify Slack - needs: [build, publish-to-pypi] + # linear-release is skipped for pre-releases and failed publishes; this job still runs. + needs: [build, publish-to-pypi, linear-release] if: ${{ !cancelled() && vars.SLACK_CHANNEL_ID != '' }} runs-on: ubuntu-latest @@ -163,6 +164,9 @@ jobs: - name: Notify Slack (release published) if: ${{ needs.publish-to-pypi.result == 'success' && !github.event.release.prerelease }} + env: + # Empty while the release step only logs, or when it failed. + RELEASE_URL: ${{ needs.linear-release.outputs.release-url }} uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 with: method: chat.postMessage @@ -173,7 +177,7 @@ jobs: thread_ts: ${{ env.THREAD_TS }} text: >- *Codeplain Client*: `${{ github.ref_name }}` published to PyPI :white_check_mark:. - Install with `pip install --upgrade codeplain`. + Install with `pip install --upgrade codeplain`.${{ env.RELEASE_URL != '' && format(' <{0}|View release>.', env.RELEASE_URL) || (needs.linear-release.result == 'failure' || needs.linear-release.outputs.outcome == 'failure') && ' Recording it in Linear failed :warning:.' || '' }} # Success also puts a ✅ on the start notice, so the result shows in the # channel without another message. Needs the app's reactions:write scope. @@ -191,51 +195,42 @@ jobs: timestamp: ${{ env.THREAD_TS }} name: white_check_mark - # Release pipelines to be turned on only after the rest of the infrastructure - # is ready. - # - # # Runs only after the package is on PyPI. Kept as its own job so the publish job - # # keeps its minimal permissions and no checkout. - # linear-release: - # name: Record release in Linear - # needs: [build, publish-to-pypi] - # runs-on: ubuntu-latest - # - # permissions: - # contents: read - # - # steps: - # - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # with: - # # The scan needs full history. - # fetch-depth: 0 - # - # # Non-fatal: the package is already published. - # - name: Record release in Linear - # id: linear - # continue-on-error: true - # uses: linear/linear-release-action@0a25abab892a91062ebf42260dbb2ce6277aa205 # v0.16.0 - # with: - # access_key: ${{ secrets.LINEAR_ACCESS_KEY }} - # version: ${{ github.ref_name }} - # # Pin the CLI the action downloads, not just the action. - # cli_version: v0.16.0 - # - # # continue-on-error keeps the job green, so this checks the step's own - # # outcome — `failure()` would never fire. - # - name: Notify Slack (release not recorded) - # if: ${{ steps.linear.outcome == 'failure' && vars.SLACK_CHANNEL_ID != '' }} - # env: - # # See the notify job. - # THREAD_TS: ${{ needs.build.outputs.slack-ts && format('"{0}"', needs.build.outputs.slack-ts) || 'null' }} - # uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 - # with: - # method: chat.postMessage - # token: ${{ secrets.SLACK_BOT_TOKEN }} - # errors: true - # payload: | - # channel: ${{ vars.SLACK_CHANNEL_ID }} - # thread_ts: ${{ env.THREAD_TS }} - # text: >- - # *Codeplain Client*: `${{ github.ref_name }}` is on PyPI, but recording it in Linear FAILED :warning:. - # See <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|the run log>. + # Stable releases only; pre-releases stay out of the Linear pipeline. Its own + # job, after the package is on PyPI, so the publish job keeps its minimal + # permissions and no checkout. + linear-release: + name: Record release in Linear + needs: publish-to-pypi + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + # pull-requests lets the step look up each commit's PR. + permissions: + contents: read + pull-requests: read + outputs: + # The notify job links the release, or says recording it failed. + release-url: ${{ steps.linear.outputs.release-url }} + outcome: ${{ steps.linear.outcome }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # The release gets the PRs merged since the previous one, and the GitHub + # release text as notes; a re-run updates it. Log-only until + # LINEAR_RELEASE_MODE is "live". Non-fatal: the package is already published. + - name: Record release in Linear + id: linear + continue-on-error: true + # A hung API call must not hold up the Slack notice. + timeout-minutes: 10 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + LINEAR_ACCESS_KEY: ${{ secrets.LINEAR_ACCESS_KEY }} + LINEAR_RELEASE_MODE: ${{ vars.LINEAR_RELEASE_MODE }} + RELEASE_VERSION: ${{ github.event.release.tag_name }} + RELEASE_SHA: ${{ github.sha }} + RELEASE_NOTES: github-release + with: + script: return require('./.github/scripts/record-linear-release.cjs')({ github, context, core })