diff --git a/.github/scripts/ci/main-failure-signature.mjs b/.github/scripts/ci/main-failure-signature.mjs index 36dc18a3908..dcb884fdfc4 100644 --- a/.github/scripts/ci/main-failure-signature.mjs +++ b/.github/scripts/ci/main-failure-signature.mjs @@ -156,6 +156,10 @@ const ALSO_FAILING_HEADING = '## Also failing'; // under it. const ALSO_FAILING_BLOCK = /\n*##\s+Also failing\s*\n+(?:- [^\n]*\n?)+/; +// The same split/merge contract — head / recorded occurrences / tail around +// the marker, human text kept verbatim, occurrences newest-first and capped — +// is re-implemented in bash/awk by .github/scripts/image-build-failure-issue.sh +// for the build-and-publish-image workflow; a fix to one must reach the other. function splitOccurrenceBlock(body) { const index = body.indexOf(OCCURRENCE_MARKER); if (index === -1) return { head: body.trimEnd(), lines: [], tail: '' }; diff --git a/.github/scripts/image-build-failure-issue.sh b/.github/scripts/image-build-failure-issue.sh new file mode 100755 index 00000000000..2c8594d2ae1 --- /dev/null +++ b/.github/scripts/image-build-failure-issue.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# File (or update) one issue per version when the sandbox image build job +# fails. The job gate is the WHOLE build job — checkout, version processing, +# QEMU/buildx setup, metadata extraction, registry login, or either build +# step — so the wording below must not assert which step failed. +# +# The body below is the 'File or update the image-build failure issue' step +# of the file-failure-issue job in .github/workflows/build-and-publish-image.yml. +# A released npm version without a matching GHCR sandbox image breaks every +# sandbox-based CI lane (/resolve, sandboxed review, autofix) with +# "manifest unknown", and nothing else surfaces that state — see #9898. +set -euo pipefail + +# Tag pushes name the version through the tag; manual recovery dispatches +# carry it in the version input. +if [[ "${EVENT_NAME}" == 'push' ]]; then + version="${TAG_NAME}" +else + version="${INPUT_VERSION}" +fi +# Both paths may carry a leading `v` (tag names always do; a dispatcher may +# type one). Normalize once so the dedup marker and the image tag — which the +# build job publishes without a `v` — always agree, instead of filing a +# duplicate issue for a `v`-prefixed tag that can never exist. +version="${version#v}" +if [[ -z "${version}" ]]; then + echo "::error::No version resolved for the image-build failure issue." + exit 1 +fi +marker="image-build-failure:${version}" +marker_html="" + +# Dedup by an exact body marker, matched CLIENT-side: GitHub search +# tokenizes the colon out of the marker, so a search-based lookup +# never finds the issues this job files. +issues_file="${RUNNER_TEMP}/open-issues.json" +gh issue list \ + --repo "${REPO}" \ + --state open \ + --label "${DEDUP_LABEL}" \ + --json number,body \ + --limit 200 \ + > "${issues_file}" +existing="$( + jq -r --arg marker_html "${marker_html}" \ + '.[] | select(.body | contains($marker_html)) | .number' \ + "${issues_file}" \ + | head -n 1 +)" + +# The machine-owned recurrence block: every recorded failed run is a bullet +# under this marker, newest first. On recurrence ONLY this block is rebuilt — +# hand-written annotations anywhere else in the body survive verbatim. This is +# the same merge contract splitOccurrenceBlock()/renderIssueBody() in +# .github/scripts/ci/main-failure-signature.mjs implements for +# main-ci-failure-issue.yml; a fix to one must be applied to the other. +runs_heading='## Failed runs' +occurrences_marker='' +max_runs=10 + +body_file="${RUNNER_TEMP}/image-build-failure.md" +head_file="${RUNNER_TEMP}/body-head.md" +runs_file="${RUNNER_TEMP}/body-runs.txt" + +# The backticks in these formats are literal markdown, not command +# substitution, so shellcheck's SC2016 expansion warning is disabled. +# shellcheck disable=SC2016 +write_prose() { + printf '%s\n' "${marker_html}" + printf '\n' + printf 'The release build job for `%s` failed before `ghcr.io/qwenlm/qwen-code:%s` could be published.\n' "${version}" "${version}" + printf '\n' + printf 'Until the image exists, every sandbox-based CI lane (`/resolve`, sandboxed review, autofix) crashes with `manifest unknown` when it installs the matching npm version.\n' + printf '\n' + printf 'Open the newest run below to see which step failed, then rerun the failed jobs (transient failures — for example buildx `ETXTBSY` races during the build steps — usually pass on retry), or dispatch `Build and Publish Docker Image` with `version=%s`, `publish=true`.\n' "${version}" +} + +write_body() { + { + cat "${head_file}" + printf '\n%s\n\n%s\n' "${runs_heading}" "${occurrences_marker}" + cat "${runs_file}" + } > "${body_file}" +} + +if [[ -z "${existing}" ]]; then + write_prose > "${head_file}" + printf -- '- %s\n' "${RUN_URL}" > "${runs_file}" + write_body + gh issue create \ + --repo "${REPO}" \ + --title "Sandbox image for ${version} not published: release build job failed" \ + --body-file "${body_file}" \ + --label 'type/bug' \ + --label "${DEDUP_LABEL}" + exit 0 +fi + +# Recurrence: re-plan against the existing body instead of overwriting it. +existing_body="${RUNNER_TEMP}/existing-body.md" +gh issue view "${existing}" \ + --repo "${REPO}" \ + --json body \ + --jq '.body' > "${existing_body}" + +tail_file="${RUNNER_TEMP}/body-tail.md" +: > "${head_file}" +: > "${runs_file}" +: > "${tail_file}" +# Split head / recorded runs / tail around the occurrences marker. Anything +# that is not a recorded-run bullet below the marker was written by a human; +# it lands in the tail and is re-emitted with the head prose. +awk -v marker="${occurrences_marker}" \ + -v head_f="${head_file}" -v runs_f="${runs_file}" -v tail_f="${tail_file}" ' + BEGIN { state = "head" } + state == "head" { + if ($0 == marker) { state = "runs"; next } + print > head_f + next + } + state == "runs" { + line = $0 + sub(/^[ \t]+/, "", line) + sub(/[ \t]+$/, "", line) + if (line == "") next + if (line ~ /^- https:\/\/[^ ]+\/actions\/runs\/[0-9]+$/) { print > runs_f; next } + state = "tail" + } + state == "tail" { print > tail_f; next } +' "${existing_body}" + +# Drop trailing blank lines, and a stranded heading left behind if the +# occurrences marker line was edited away — the rebuilt block re-emits +# both. sed, not `head -n -1`: BSD head rejects negative line counts. +printf '%s\n' "$(cat "${head_file}")" > "${head_file}" +if [[ "$(tail -n 1 "${head_file}")" == "${runs_heading}" ]]; then + printf '%s\n' "$(sed '$d' "${head_file}")" > "${head_file}" +fi +# Re-check AFTER the strip, which can itself empty the head: fall back to +# the generated prose so the narrative (and the dedup marker it carries) +# is never lost. +if [[ -z "$(cat "${head_file}")" ]]; then + write_prose > "${head_file}" +fi + +if [[ -s "${tail_file}" ]]; then + printf '\n' >> "${head_file}" + cat "${tail_file}" >> "${head_file}" +fi + +# Newest first; a re-run of the same run must not add a second line for it. +# awk (not head) applies the cap so the pipeline never dies on SIGPIPE. +{ printf -- '- %s\n' "${RUN_URL}"; cat "${runs_file}"; } \ + | awk -v max="${max_runs}" '!seen[$0]++ && ++n <= max' \ + > "${runs_file}.merged" +mv "${runs_file}.merged" "${runs_file}" + +write_body +gh issue edit "${existing}" \ + --repo "${REPO}" \ + --body-file "${body_file}" +echo "Recorded this failure on issue #${existing}." diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 0e40ffccd38..ccfc3f76a4c 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -15,7 +15,7 @@ 2226 assign-issue-owner.yml 3480 audio-capture-prebuilds.yml 9023 auto-minimize-spam.yml -4638 build-and-publish-image.yml +9256 build-and-publish-image.yml 49610 cd-cua-driver.yml 2076 cd-mobile-mcp.yml 74315 ci.yml diff --git a/.github/workflows/build-and-publish-image.yml b/.github/workflows/build-and-publish-image.yml index 0c0daba6026..96fa1b7cf33 100644 --- a/.github/workflows/build-and-publish-image.yml +++ b/.github/workflows/build-and-publish-image.yml @@ -25,8 +25,26 @@ jobs: permissions: contents: 'read' packages: 'write' + outputs: + # Job-level `if:` cannot read the env context, so PUSH_IMAGE leaves the + # build job through this output and file-failure-issue gates on it + # instead of restating the publish predicate (which would drift apart + # from PUSH_IMAGE the next time the predicate changes). + push_image: '${{ steps.publish-decision.outputs.push_image }}' + env: + # Whether this run publishes. Defined once at the job level so the + # login gate and both build steps cannot drift apart. + PUSH_IMAGE: |- + ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') }} steps: + # First on purpose: this output gates file-failure-issue, and it must + # exist even when every later step fails — a checkout or login failure + # on a publishing run is exactly a run the reporting job must cover. + - name: 'Export the publish decision' + id: 'publish-decision' + run: 'echo "push_image=${PUSH_IMAGE}" >> "$GITHUB_OUTPUT"' + - name: 'Checkout repository' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: @@ -97,23 +115,91 @@ jobs: type=sha,prefix=sha-,format=short,enable=${{ steps.version.outputs.clean == '' }} - name: 'Log in to the Container registry' - if: |- - ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') }} + if: "${{ env.PUSH_IMAGE == 'true' }}" uses: 'docker/login-action@v4' # ratchet:exclude with: registry: '${{ env.REGISTRY }}' username: '${{ github.actor }}' password: '${{ secrets.GITHUB_TOKEN }}' + # continue-on-error is load-bearing: a failed first attempt must not + # pre-fail the job, or a successful retry below would still leave the + # job red and file-failure-issue would report an image that WAS + # published. The job only fails when the retry fails too. - name: 'Build and push Docker image' id: 'build-and-push' + continue-on-error: true + uses: 'docker/build-push-action@v7' # ratchet:exclude + with: + context: '.' + platforms: 'linux/amd64,linux/arm64' + push: '${{ env.PUSH_IMAGE }}' + tags: '${{ steps.meta.outputs.tags }}' + labels: '${{ steps.meta.outputs.labels }}' + build-args: | + CLI_VERSION_ARG=${{ steps.version.outputs.clean || github.sha }} + + # One bounded retry: the docker build hits transient buildx races such as + # ETXTBSY during `npm ci` (the v0.22.0 tag build died this way and the + # image was never published, breaking every sandbox-based CI lane — see + # issue #9898). A retry reuses the build cache and almost always passes; + # a genuine failure fails this step and with it the job. + - name: 'Build and push Docker image (retry)' + id: 'build-and-push-retry' + if: "${{ steps.build-and-push.outcome == 'failure' }}" uses: 'docker/build-push-action@v7' # ratchet:exclude with: context: '.' platforms: 'linux/amd64,linux/arm64' - push: |- - ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') }} + push: '${{ env.PUSH_IMAGE }}' tags: '${{ steps.meta.outputs.tags }}' labels: '${{ steps.meta.outputs.labels }}' build-args: | CLI_VERSION_ARG=${{ steps.version.outputs.clean || github.sha }} + + # One issue per version when the release build job fails (the gate covers + # the whole job, not only the build steps — the issue wording stays + # step-agnostic for the same reason). A released npm version without a + # sandbox image silently breaks every sandbox-based CI lane. Covers + # publishing dispatches too: that is the recovery path the + # issue body recommends. Logic lives in the script named below. A + # publishing dispatch without a version input is skipped by design: the + # build job tags such runs with its branch/sha fallback, but this job + # files one issue per VERSION and cannot dedup a versionless failure. + # Known residual gap: a build job that fails before its first step runs + # at all (e.g. runner provisioning failure) never executes + # publish-decision, so push_image stays empty and this gate skips the + # job even though failure() is true — no issue gets filed. A "failed + # publish, no issue filed" investigation should start here; a scheduled + # npm-vs-GHCR reconciliation is the remaining backstop for this case. + # Second known gap: dedup needs the scope/ci-cd label the create call + # applies and the version marker in the issue body; if a human removes + # either, the lookup misses the tracked issue and the next failure files + # a duplicate. The edit call cannot repair either because the edit path + # is unreachable without them — same reconciliation backstop as above. + file-failure-issue: + needs: ['build-and-push-to-ghcr'] + if: |- + ${{ failure() && github.repository == 'QwenLM/qwen-code' && needs.build-and-push-to-ghcr.outputs.push_image == 'true' && (github.event_name == 'push' || github.event.inputs.version != '') }} + runs-on: 'ubuntu-latest' + timeout-minutes: 5 + permissions: + # checkout needs contents even though the job only files an issue. + contents: 'read' + issues: 'write' + steps: + - name: 'Checkout repository' + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + with: + persist-credentials: false + + - name: 'File or update the image-build failure issue' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + REPO: '${{ github.repository }}' + EVENT_NAME: '${{ github.event_name }}' + TAG_NAME: '${{ github.ref_name }}' + INPUT_VERSION: '${{ github.event.inputs.version }}' + DEDUP_LABEL: 'scope/ci-cd' + RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: 'bash .github/scripts/image-build-failure-issue.sh' diff --git a/scripts/tests/build-and-publish-image-workflow.test.js b/scripts/tests/build-and-publish-image-workflow.test.js index 35cce628d9a..5cacf024753 100644 --- a/scripts/tests/build-and-publish-image-workflow.test.js +++ b/scripts/tests/build-and-publish-image-workflow.test.js @@ -4,7 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; const workflow = readFileSync( @@ -19,6 +28,24 @@ const metadataStep = workflow.match( /- name: 'Extract metadata \(tags, labels\) for Docker'[\s\S]*?(?=\n[ ]{6}- name: 'Log in to the Container registry')/, )?.[0] ?? ''; +const buildStep = + workflow.match( + /- name: 'Build and push Docker image'\n[\s\S]*?(?=\n[ ]{6}# One bounded retry)/, + )?.[0] ?? ''; +const retryStep = + workflow.match( + /- name: 'Build and push Docker image \(retry\)'[\s\S]*?(?=\n[ ]{2}# One issue per version)/, + )?.[0] ?? ''; +const failureIssueJob = + workflow.match(/file-failure-issue:[\s\S]*$/)?.[0] ?? ''; +const buildJob = + workflow.match( + /build-and-push-to-ghcr:[\s\S]*?(?=\n[ ]{2}# One issue per version)/, + )?.[0] ?? ''; +const failureIssueScript = readFileSync( + '.github/scripts/image-build-failure-issue.sh', + 'utf8', +); describe('build-and-publish-image workflow', () => { it('marks only stable three-part semver versions as stable', () => { @@ -40,4 +67,594 @@ describe('build-and-publish-image workflow', () => { "type=raw,value=latest,enable=${{ steps.version.outputs.is_stable_semver == 'true' }}", ); }); + + it('keeps a failed first build from pre-failing the job', () => { + // Without continue-on-error a successful retry would leave the job red + // (GitHub computes the job conclusion from every step conclusion), which + // would make file-failure-issue report an image that WAS published. + expect(buildStep).toContain('continue-on-error: true'); + }); + + it('gates the retry on the first attempt outcome only', () => { + expect(retryStep).toContain( + 'if: "${{ steps.build-and-push.outcome == \'failure\' }}"', + ); + // failure() would be false once continue-on-error absorbs the first + // attempt, silently skipping the retry. + expect(retryStep).not.toContain('failure()'); + }); + + it('pins the first build step id the retry gate references', () => { + // steps.build-and-push.outcome only resolves when this exact id exists; + // renaming the step would silently disable the retry. + expect(buildStep).toContain("id: 'build-and-push'"); + }); + + it('lets a failed retry fail the job', () => { + // continue-on-error on the retry would absorb a genuine build failure and + // leave the job green, so file-failure-issue would never run. + expect(retryStep).not.toContain('continue-on-error'); + }); + + it('publishes from both build steps through one shared expression', () => { + expect(buildStep).toContain("push: '${{ env.PUSH_IMAGE }}'"); + expect(retryStep).toContain("push: '${{ env.PUSH_IMAGE }}'"); + expect(workflow).toContain('PUSH_IMAGE: |-'); + }); + + it('pins the PUSH_IMAGE value and the login gate at the definition site', () => { + // Pinning only the key's existence lets `${{ false }}` pass: login is + // skipped, both builds run with push: false, the job concludes green, + // and file-failure-issue never fires — a released version would ship + // with no GHCR image, silently reproducing incident #9898. + expect(workflow).toContain( + "PUSH_IMAGE: |-\n ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') }}", + ); + expect(workflow).toContain( + "- name: 'Log in to the Container registry'\n if: \"${{ env.PUSH_IMAGE == 'true' }}\"", + ); + }); + + it('gates the failure-issue job on the exported publish decision', () => { + // The publish predicate lives only in PUSH_IMAGE. Job-level `if:` cannot + // read the env context, so the decision reaches the reporting job through + // a build-job output; restating the predicate in the gate would let the + // two drift apart (a widened PUSH_IMAGE whose failed run files no issue, + // or a narrowed one filing bogus issues). + expect(failureIssueJob).toContain( + "needs.build-and-push-to-ghcr.outputs.push_image == 'true'", + ); + expect(failureIssueJob).not.toContain('startsWith(github.ref,'); + expect(failureIssueJob).not.toContain('github.event.inputs.publish'); + }); + + it('skips the failure-issue job for publishing dispatches without a version', () => { + // The dispatch arm of the gate admits an empty version input (required: + // false), which the build job services with its branch/sha fallback + // tags. The reporting job files one issue per VERSION and hard-fails on + // a versionless run, so the gate skips that combination by design. Tag + // pushes always carry a version through the tag name. + expect(failureIssueJob).toContain( + "(github.event_name == 'push' || github.event.inputs.version != '')", + ); + }); + + it('exports the publish decision before any step that can fail', () => { + expect(buildJob).toContain( + "push_image: '${{ steps.publish-decision.outputs.push_image }}'", + ); + expect(buildJob).toContain("id: 'publish-decision'"); + // The gate output must exist even when checkout or a build step fails, + // so the exporting step runs first and reads the job-level PUSH_IMAGE. + expect(buildJob).toContain( + 'echo "push_image=${PUSH_IMAGE}" >> "$GITHUB_OUTPUT"', + ); + expect(buildJob.indexOf("'Export the publish decision'")).toBeLessThan( + buildJob.indexOf("'Checkout repository'"), + ); + }); + + it('only runs the failure-issue job after a failed in-repo build', () => { + // failure() keeps green builds from filing, the repository guard keeps + // forks from filing, and needs wires the job to the build it reports on. + // Deleting any of these must fail the suite. + expect(failureIssueJob).toContain( + "failure() && github.repository == 'QwenLM/qwen-code'", + ); + expect(failureIssueJob).toContain("needs: ['build-and-push-to-ghcr']"); + }); + + it('dedups the failure issue by an exact client-side marker match', () => { + // GitHub search tokenizes the colon out of the marker, so a --search + // lookup never finds the issues this job files. + expect(failureIssueJob).toContain( + 'bash .github/scripts/image-build-failure-issue.sh', + ); + expect(failureIssueScript).not.toContain('--search'); + expect(failureIssueScript).toContain( + 'jq -r --arg marker_html "${marker_html}"', + ); + expect(failureIssueScript).toContain('contains($marker_html)'); + }); }); + +// Replay the script under a recording gh stub instead of pinning only the +// mechanism's text: text pins stayed green when `.body` was mutated to +// `.title` (lookup matches nothing, the script dies, nothing is filed) and +// when the marker lost its `:${version}` suffix (every later version +// rewrites the first issue). jq is preinstalled on ubuntu-latest runners. +// The replay also needs POSIX paths and an extensionless gh stub, which the +// Windows lane cannot express (backslash RUNNER_TEMP, ';'-separated PATH); +// it skips there while the YAML suite above still runs. +const replayable = + process.platform !== 'win32' && spawnSync('jq', ['--version']).status === 0; + +describe.skipIf(!replayable)( + 'image-build-failure-issue script behavior', + () => { + const runScript = ({ + eventName, + tagName = '', + inputVersion = '', + issues, + }) => { + const dir = mkdtempSync(join(tmpdir(), 'image-failure-issue-')); + const callsLog = join(dir, 'calls.log'); + const bodyCapture = join(dir, 'captured-body.md'); + // Not open-issues.json: the script redirects `gh issue list` into + // ${RUNNER_TEMP}/open-issues.json, which would truncate this fixture. + const fixture = join(dir, 'fixture-issues.json'); + writeFileSync(fixture, JSON.stringify(issues)); + // The recurrence path fetches the tracked issue's body through + // `gh issue view `; serve it from a per-issue fixture file. + for (const issue of issues) { + writeFileSync( + join(dir, `issue-${issue.number}.body`), + issue.body ?? '', + ); + } + writeFileSync( + join(dir, 'gh'), + [ + '#!/bin/bash', + 'echo "gh $*" >> "' + callsLog + '"', + 'prev=""', + 'for arg in "$@"; do', + ' if [[ "$prev" == "--body-file" ]]; then cp "$arg" "' + + bodyCapture + + '"; fi', + ' prev="$arg"', + 'done', + 'case "$1 $2" in', + ' "issue list") cat "' + fixture + '" ;;', + ' "issue view") cat "' + dir + '/issue-$3.body" ;;', + 'esac', + 'exit 0', + '', + ].join('\n'), + ); + chmodSync(join(dir, 'gh'), 0o755); + const result = spawnSync( + 'bash', + ['.github/scripts/image-build-failure-issue.sh'], + { + encoding: 'utf8', + env: { + PATH: dir + ':' + (process.env.PATH ?? ''), + REPO: 'QwenLM/qwen-code', + EVENT_NAME: eventName, + TAG_NAME: tagName, + INPUT_VERSION: inputVersion, + DEDUP_LABEL: 'scope/ci-cd', + RUN_URL: + 'https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + RUNNER_TEMP: dir, + }, + }, + ); + return { + status: result.status, + out: result.stdout, + calls: existsSync(callsLog) ? readFileSync(callsLog, 'utf8') : '', + body: existsSync(bodyCapture) ? readFileSync(bodyCapture, 'utf8') : '', + }; + }; + + it('records the recurrence on the existing issue instead of creating', () => { + const previousRun = + 'https://github.com/QwenLM/qwen-code/actions/runs/32580000000'; + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '1.2.3', + issues: [ + { + number: 42, + title: + 'Sandbox image for 1.2.3 not published: release build job failed', + body: + '\n' + + '\n' + + 'The release build job for `1.2.3` failed before the image could be published.\n' + + '\n' + + '## Failed runs\n' + + '\n' + + '\n' + + `- ${previousRun}\n`, + }, + { number: 43, title: 'unrelated', body: 'no marker here' }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + expect(result.calls).not.toContain('issue create'); + // The dedup lookup must stay open-only: with `--state all` a closed + // marker issue (image recovered) matches the next failure and the + // script edits the CLOSED issue instead of opening a fresh alert — + // the silent-failure mode this PR exists to prevent. Only the list + // call carries this substring. + expect(result.calls).toContain('--state open'); + // The lookup side of the dedup contract: the list call filters on the + // same label the create call applies. Without the filter the lookup + // scans the newest 200 of ALL open issues; an older marker issue can + // fall out of that window and the script files a duplicate. + expect(result.calls).toContain( + 'issue list --repo QwenLM/qwen-code --state open --label scope/ci-cd', + ); + expect(result.body).toContain(''); + // The new run is appended to the recorded list, newest first, instead + // of replacing it — the previous run URL must survive. + expect(result.body).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + ); + expect(result.body).toContain(`- ${previousRun}`); + expect(result.body.indexOf('32580293377')).toBeLessThan( + result.body.indexOf('32580000000'), + ); + }); + + it('keeps hand-written annotations on recurrence instead of wiping them', () => { + const annotation = + 'Do not republish — the npm package is broken, tracked in #9999.'; + const previousRun = + 'https://github.com/QwenLM/qwen-code/actions/runs/32580000000'; + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '1.2.3', + issues: [ + { + number: 42, + title: + 'Sandbox image for 1.2.3 not published: release build job failed', + body: + '\n' + + '\n' + + 'The release build job for `1.2.3` failed before the image could be published.\n' + + '\n' + + '## Failed runs\n' + + '\n' + + '\n' + + `- ${previousRun}\n` + + '\n' + + `${annotation}\n`, + }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + expect(result.body).toContain(annotation); + expect(result.body).toContain(`- ${previousRun}`); + expect(result.body).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + ); + }); + + it('keeps hand-written notes when the existing body predates the run block', () => { + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '1.2.3', + issues: [ + { + number: 42, + title: + 'Sandbox image for 1.2.3 not published: release build job failed', + body: '\n\nHand-written note.', + }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + expect(result.calls).not.toContain('issue create'); + expect(result.body).toContain('Hand-written note.'); + expect(result.body).toContain(''); + expect(result.body).toContain( + 'https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + ); + }); + + it('does not record the same run twice on a repeated failure', () => { + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '1.2.3', + issues: [ + { + number: 42, + title: + 'Sandbox image for 1.2.3 not published: release build job failed', + body: + '\n' + + '\n' + + '## Failed runs\n' + + '\n' + + '\n' + + '- https://github.com/QwenLM/qwen-code/actions/runs/32580293377\n', + }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + expect(result.body.match(/actions\/runs\/32580293377/g)).toHaveLength(1); + }); + + it('caps the recorded runs at the newest ten', () => { + // Ten existing runs (newest first, as the script maintains the block) + // plus this one must drop the oldest and keep exactly ten. + const runs = Array.from( + { length: 10 }, + (_, i) => + `- https://github.com/QwenLM/qwen-code/actions/runs/3258000000${ + 9 - i + }`, + ); + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '1.2.3', + issues: [ + { + number: 42, + body: + '\n' + + '\n' + + '## Failed runs\n' + + '\n' + + '\n' + + runs.join('\n') + + '\n', + }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + const bullets = + result.body.match( + /^- https:\/\/github\.com\/QwenLM\/qwen-code\/actions\/runs\/\d+$/gm, + ) ?? []; + expect(bullets).toHaveLength(10); + expect(result.body).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + ); + expect(result.body).not.toContain('32580000000'); + expect(result.body.indexOf('32580293377')).toBeLessThan( + result.body.indexOf('32580000009'), + ); + }); + + it('emits the run-block heading exactly once after a stranded one', () => { + // A human edit deleted the occurrences marker line and every bullet, + // leaving the head ending on a stranded '## Failed runs' that the + // rebuilt block must absorb, not duplicate. + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '1.2.3', + issues: [ + { + number: 42, + body: + '\n' + + '\n' + + 'The release build job for `1.2.3` failed before the image could be published.\n' + + '\n' + + '## Failed runs\n', + }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + expect(result.body.match(/## Failed runs/g)).toHaveLength(1); + expect(result.body).toContain(''); + expect(result.body).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + ); + }); + + it('keeps the dedup marker when it survives only outside the head prose', () => { + // The marker can sit on a line the split re-emits with the tail; the + // rebuilt body must still carry it or the next failure files a + // duplicate and orphans the tracked issue. + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '1.2.3', + issues: [ + { + number: 42, + body: + 'The release build job for `1.2.3` failed before the image could be published.\n' + + '\n' + + '## Failed runs\n' + + '\n' + + '\n' + + '- https://github.com/QwenLM/qwen-code/actions/runs/32580000000 \n', + }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + expect(result.calls).not.toContain('issue create'); + expect(result.body).toContain(''); + expect(result.body).toContain('The release build job for `1.2.3` failed'); + expect(result.body).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + ); + }); + + it('restores the narrative when the body starts at the occurrences marker', () => { + // Nothing above the marker: the empty head falls back to the generated + // prose, so the narrative is never lost. The pre-existing marker below + // the block is re-emitted with the tail, so two copies are expected. + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '1.2.3', + issues: [ + { + number: 42, + body: + '\n' + + '- https://github.com/QwenLM/qwen-code/actions/runs/32580000000\n' + + '\n', + }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + expect(result.body).toContain('The release build job for `1.2.3` failed'); + expect(result.body).toContain(''); + expect(result.body).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580000000', + ); + expect(result.body).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + ); + }); + + it('restores the narrative when the head normalizes down to nothing', () => { + // Only a stranded heading above the marker: the normalization strip + // empties the head AFTER the initial readability check, so the prose + // fallback must run again after the strip. + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '1.2.3', + issues: [ + { + number: 42, + body: + '## Failed runs\n' + + '\n' + + '\n' + + '- https://github.com/QwenLM/qwen-code/actions/runs/32580000000\n' + + '\n' + + '\n', + }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + expect(result.body).toContain('The release build job for `1.2.3` failed'); + expect(result.body).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580000000', + ); + expect(result.body).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + ); + }); + + it('keeps a bullet-shaped annotation out of the recorded runs', () => { + const annotation = + '- do not republish — the npm package is broken, tracked in #9999.'; + const firstRun = + 'https://github.com/QwenLM/qwen-code/actions/runs/32580000000'; + const secondRun = + 'https://github.com/QwenLM/qwen-code/actions/runs/32580000001'; + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '1.2.3', + issues: [ + { + number: 42, + body: + '\n' + + '\n' + + '## Failed runs\n' + + '\n' + + '\n' + + `- ${firstRun}\n` + + `${annotation}\n` + + `- ${secondRun}\n`, + }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + expect(result.body).toContain(annotation); + expect(result.body).toContain(`- ${secondRun}`); + // The annotation is human prose, not a recorded run: it must not be + // reordered into the machine block or counted against the run cap. + const block = result.body.split( + '', + )[1]; + expect(block).toContain(`- ${firstRun}`); + expect(block).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + ); + expect(block).not.toContain('do not republish'); + }); + + it('creates a new issue when no open issue carries the version marker', () => { + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '4.5.6', + issues: [ + { + number: 42, + title: + 'Sandbox image for 1.2.3 not published: release build job failed', + body: '', + }, + ], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue create'); + expect(result.calls).not.toContain('issue edit'); + // The dedup lookup filters on DEDUP_LABEL, so the create call must + // apply it or every later lookup finds nothing and files duplicates. + // Scoped to the create line: the list call carries the label too. + const createCall = result.calls + .split('\n') + .find((call) => call.startsWith('gh issue create')); + expect(createCall).toContain('--label scope/ci-cd'); + expect(result.calls).toContain( + 'Sandbox image for 4.5.6 not published: release build job failed', + ); + expect(result.body).toContain(''); + expect(result.body).toContain('## Failed runs'); + expect(result.body).toContain(''); + expect(result.body).toContain( + '- https://github.com/QwenLM/qwen-code/actions/runs/32580293377', + ); + }); + + it('matches a v-prefixed tag push against the versioned marker', () => { + // Tag pushes name the version through the tag; dispatches through the + // input. Both must normalize to the same marker or the second event + // files a duplicate issue that can never be deduped. + const result = runScript({ + eventName: 'push', + tagName: 'v1.2.3', + issues: [{ number: 42, body: '' }], + }); + expect(result.status).toBe(0); + expect(result.calls).toContain('gh issue edit 42'); + expect(result.calls).not.toContain('issue create'); + }); + + it('hard-fails instead of filing when no version resolves', () => { + const result = runScript({ + eventName: 'workflow_dispatch', + inputVersion: '', + issues: [], + }); + expect(result.status).toBe(1); + expect(result.out).toContain('::error::'); + expect(result.calls).not.toContain('issue create'); + expect(result.calls).not.toContain('issue edit'); + }); + }, +);