diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 184a2134d4..d749ec82f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,13 +33,14 @@ jobs: code: ${{ steps.filter.outputs.code }} desktop: ${{ steps.filter.outputs.desktop }} sdk: ${{ steps.filter.outputs.sdk }} + scripts: ${{ steps.filter.outputs.scripts }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - id: filter run: | - code=true; desktop=true; sdk=true + code=true; desktop=true; sdk=true; scripts=true base="" if [ "${{ github.event_name }}" = "pull_request" ]; then base="${{ github.event.pull_request.base.sha }}" @@ -49,7 +50,7 @@ jobs: if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then files=$(git diff --name-only "$base" HEAD) if [ -n "$files" ]; then - code=false; desktop=false; sdk=false + code=false; desktop=false; sdk=false; scripts=false # Root-module CI: desktop/ is a separate module, so only the # clearly unrelated paths below can skip it. if echo "$files" | grep -qvE '^(docs/|release-notes/|desktop/|workers/|[^/]+\.md$)'; then code=true; fi @@ -60,9 +61,26 @@ jobs: # its DTOs are generated from internal/ext/extension/protocol, so # both paths must trigger the sdk job. if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi + # The release-note tests render every committed Studio note. + if echo "$files" | grep -qE '^(scripts/|release-notes/studio/)'; then scripts=true; fi fi fi - { echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; } >> "$GITHUB_OUTPUT" + { echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT" + + release-notes-scripts: + needs: changes + if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.scripts != 'false') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "22" + - run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs # The ruleset requires the per-OS check names (test (ubuntu-latest) etc.). # A matrix job skipped at job level reports no per-leg checks at all, so diff --git a/.github/workflows/release-studio.yml b/.github/workflows/release-studio.yml index 5488a78d52..845cc93e24 100644 --- a/.github/workflows/release-studio.yml +++ b/.github/workflows/release-studio.yml @@ -545,11 +545,25 @@ jobs: environment: studio-release permissions: contents: write + issues: read # release-notes credits read PR and issue authors + pull-requests: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ needs.resolve.outputs.sha }} + # Before signing, so a credit lookup GitHub cannot answer stops the + # release while nothing has been published. + - name: Render version notes + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.resolve.outputs.version }} + run: | + version_notes="release-notes/studio/${VERSION#v}.md" + if [ -f "$version_notes" ]; then + node scripts/studio-release-notes.mjs "$version_notes" "$RUNNER_TEMP/version-notes.md" + fi + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: desktop/go.mod @@ -650,13 +664,14 @@ jobs: SIGNED_WINDOWS: ${{ secrets.SIGNPATH_API_TOKEN != '' && vars.STUDIO_SIGNING_ENABLED == 'true' }} run: | # A version's own notes live in release-notes/studio/.md, named - # without the v the tag carries. The release still publishes without - # one, carrying only the standing text. + # without the v the tag carries, and were rendered with their authors + # above. The release still publishes without one, carrying only the + # standing text. version_notes="release-notes/studio/${VERSION#v}.md" notes="$RUNNER_TEMP/release-notes.md" : > "$notes" if [ -f "$version_notes" ]; then - cat "$version_notes" >> "$notes" + cat "$RUNNER_TEMP/version-notes.md" >> "$notes" printf '\n' >> "$notes" else echo "::warning::no $version_notes; publishing the standing text only" diff --git a/docs/STUDIO_RELEASE.md b/docs/STUDIO_RELEASE.md index 23f8f3d13e..ed57926625 100644 --- a/docs/STUDIO_RELEASE.md +++ b/docs/STUDIO_RELEASE.md @@ -55,7 +55,7 @@ Jobs in the run: | `signing-contract` | Validates `.signpath/` locally and prints its fingerprint. | does not call SignPath | | `build` | Builds windows/amd64, darwin/amd64, darwin/arm64, linux/amd64; signs macOS and, if enabled, Windows. | Apple secrets are required | | `cli` | Builds `reasonix` archives for six OS/arch targets plus `SHA256SUMS`. | fails on a missing archive | -| `publish` | Minisigns, writes `latest.json`, creates the GitHub prerelease, mirrors to R2. | environment `studio-release` | +| `publish` | Renders the notes with their authors, minisigns, writes `latest.json`, creates the GitHub prerelease, mirrors to R2. | environment `studio-release`; an unresolved `#N` stops it before signing | The `studio-release` environment allows the `studio-v*` tag and the `studio` branch. It has no required reviewer. @@ -92,6 +92,19 @@ File: `release-notes/studio/X.Y.Z.md`, without the tag's `v`. Language: Chinese. | R4 | Every item names its issue (`#123`), pull request or commit. | | R5 | Describe what the user observes. The explanation belongs in the linked commit. | +The published body is rendered, not copied: + +| Reference | Rendered as | +| --- | --- | +| `#N`, a pull request | `#N by @author` | +| `#N`, an issue closed by merged pull requests | `#N fixed in #M by @author` | +| `#N`, an issue fixed by a direct push; a commit; a bot author | as written | +| `#N`, a discussion or no such number | as written, with a warning | + +A `## 贡献者` list of the credited authors closes the notes. A `#N` counts only at a line start or after whitespace, `(`, `(`, `、`, `,` or `,`, and never in code, an HTML comment or a link target. Write colours such as `#333` in a code span. + +A lookup GitHub refuses, or cannot answer after retries, fails `publish` with a `release_credits.*` code. Preview with `node scripts/studio-release-notes.mjs release-notes/studio/X.Y.Z.md /tmp/notes.md`; it reads `GH_TOKEN`, else `gh auth token`. + ```markdown 本版修复读图模型误报看不到图,并让被 ACL 残留阻塞的 Windows 安装恢复启动。 diff --git a/scripts/markdown-refs.mjs b/scripts/markdown-refs.mjs new file mode 100644 index 0000000000..e1bad4371a --- /dev/null +++ b/scripts/markdown-refs.mjs @@ -0,0 +1,89 @@ +// Finds the #N references in Markdown prose. A number counts only where a +// reference is written: at the start of a line, or after whitespace, "(", "(" +// or a list separator ("、", ",", ","). Fenced and indented code, code spans, +// HTML comments, link targets ("](#N") and escaped "\#N" are never references. + +const refPattern = /(?<=^|[\s(、,,]|(? { + const blank = line.trim() === ""; + const opening = /^ {0,3}(`{3,}|~{3,})/.exec(line); + if (fence) { + const closing = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line); + if (closing && closing[1][0] === fence[0] && closing[1].length >= fence.length) fence = null; + previousBlank = false; + return masked.repeat(line.length); + } + if (opening && !comment) { + fence = opening[1]; + previousBlank = false; + return masked.repeat(line.length); + } + const indented = /^( {4}|\t)/.test(line); + if (!comment && indented && !inList && (previousBlank || previousCode)) { + previousCode = true; + previousBlank = false; + return masked.repeat(line.length); + } + previousCode = false; + if (listItemPattern.test(line)) inList = true; + else if (!blank && !indented) inList = false; + previousBlank = blank; + + let text = line; + for (const match of text.matchAll(codeSpanPattern)) { + if (!comment) text = mask(text, match.index, match.index + match[0].length); + } + let from = 0; + while (from <= text.length) { + if (comment) { + const end = text.indexOf("-->", from); + if (end === -1) return mask(text, from, text.length); + text = mask(text, from, end + 3); + from = end + 3; + comment = false; + } + const start = text.indexOf(" 可见 #3", "", "#6